diff --git a/.gitignore b/.gitignore index 9271d4f..dee4292 100644 --- a/.gitignore +++ b/.gitignore @@ -69,4 +69,5 @@ gradle-app.setting # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 # gradle/wrapper/gradle-wrapper.properties -config.ini \ No newline at end of file +config.ini +permissions.json \ No newline at end of file diff --git a/build.gradle b/build.gradle index 607ad16..897d6a6 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,7 @@ dependencies { compile "org.ini4j:ini4j:0.5.4" compile "org.slf4j:slf4j-simple:1.7.9" compile "org.apache.commons:commons-lang3:3.0" + compile "com.google.code.gson:gson:2.6.2" } jar { diff --git a/config.example.ini b/config.example.ini index 340d052..22c6f98 100644 --- a/config.example.ini +++ b/config.example.ini @@ -3,6 +3,9 @@ ; Will fallback to "!" if the prefix is invalid or empty. commandPrefix = ! +; Bot Owner UID, obtainable using /perm:getMyUID +owner = XXXXXXXXXXXXXXXXXX + [discord] ; Change next value to true if the account is a bot, thus logins using a token. ; Don't forget to uncomment token then @@ -14,4 +17,8 @@ password = samplePassword [plugins] ; Comma-separated list of modules to load ; enable = net.pingex.discordbot.HelloWorldModule, net.pingex.discordbot.AnothaModule, fr.w4rell.discordbot.ThemeSombreModule -enable = net.pingex.discordbot.HelloWorldModule \ No newline at end of file +enable = net.pingex.discordbot.HelloWorldModule + +[permissions] +; The location of the permissions file, defaulting to `permissions.json` if not specified +file = permissions.json \ No newline at end of file diff --git a/src/main/java/net/pingex/discordbot/Command.java b/src/main/java/net/pingex/discordbot/Command.java index 922af9b..29d55b7 100644 --- a/src/main/java/net/pingex/discordbot/Command.java +++ b/src/main/java/net/pingex/discordbot/Command.java @@ -23,4 +23,9 @@ public @interface Command * Shortened command */ String shorthand() default ""; + + /** + * Default permission for this command + */ + DefaultPermission permission() default DefaultPermission.EVERYONE; } diff --git a/src/main/java/net/pingex/discordbot/CommandDispatcher.java b/src/main/java/net/pingex/discordbot/CommandDispatcher.java index 8d8ea3a..263a21a 100644 --- a/src/main/java/net/pingex/discordbot/CommandDispatcher.java +++ b/src/main/java/net/pingex/discordbot/CommandDispatcher.java @@ -111,20 +111,30 @@ class CommandDispatcher parsedArray = new Object[foundMethod.getMethod().getParameterCount()]; parsedArray[0] = event; - if(foundMethod.getMethod().getParameterCount()-1 == args.size()) + // To be redone, maybe ? + Boolean canOverrideRun = PermissionsModule.getInstance().canRun(event.getMessage().getGuild(), event.getMessage().getAuthor(), fullCommand); + if(!foundMethod.getMethod().getAnnotation(Command.class).permission().eval(event)) + commandAnswer = "Permission denied."; + if(canOverrideRun != null) + commandAnswer = canOverrideRun ? null : "Permission denied."; + + if(commandAnswer == null) { - for(int i=1; i < foundMethod.getMethod().getParameterCount(); i++) - try - { - parsedArray[i] = parse(foundMethod.getMethod().getParameterTypes()[i], args.get(i-1)); - } catch (IllegalArgumentException e) - { - commandAnswer = "Failed to parse arguments, are they correct ? " + commandList.get("internal:help").invoke(event, fullCommand); - break; - } + if(foundMethod.getMethod().getParameterCount()-1 == args.size()) + { + for(int i=1; i < foundMethod.getMethod().getParameterCount(); i++) + try + { + parsedArray[i] = parse(foundMethod.getMethod().getParameterTypes()[i], args.get(i-1)); + } catch (IllegalArgumentException e) + { + commandAnswer = "Failed to parse arguments, are they correct ? " + commandList.get("internal:help").invoke(event, fullCommand); + break; + } + } + else + commandAnswer = "Invalid arguments. " + commandList.get("internal:help").invoke(event, fullCommand); } - else - commandAnswer = "Invalid arguments. " + commandList.get("internal:help").invoke(event, fullCommand); } else commandAnswer = "Unknown command"; diff --git a/src/main/java/net/pingex/discordbot/DefaultPermission.java b/src/main/java/net/pingex/discordbot/DefaultPermission.java new file mode 100644 index 0000000..e73fedb --- /dev/null +++ b/src/main/java/net/pingex/discordbot/DefaultPermission.java @@ -0,0 +1,56 @@ +package net.pingex.discordbot; + +import sx.blah.discord.handle.impl.events.MessageReceivedEvent; +import java.util.function.Predicate; + +/** + * Enum describing the default behavior of a command when no specific permission override is given. + * @version 0.1-dev + * @author Raphael "Pingex" NAAS + */ +public enum DefaultPermission +{ + /** + * Everyone can call the command + */ + EVERYONE(e -> true), + + /** + * Only the owner of the guild where command was run can run the command + */ + GUILD_OWNER(e -> e.getMessage().getGuild().getOwnerID().equals(e.getMessage().getAuthor().getID())), + + /** + * Only the bot owner can run the command. Bot owner can be defined in the configuration file + */ + BOT_OWNER(e -> e.getMessage().getAuthor().getID().equals(Configuration.getValue("general", "owner"))), + + /** + * Nobody can run the command + */ + NONE(e -> false); + + /** + * The predicate for each enum value defined above + */ + private Predicate predicate; + + /** + * Quick constructor, no further explanation needed + * @param p Input Predicate + */ + DefaultPermission(Predicate p) + { + predicate = p; + } + + /** + * Evaluates the input MRE against the predicate of the said enum value + * @param e The input event, containing all needed methods to evaluate the predicate + * @return What Predicate.test() returns + */ + boolean eval(MessageReceivedEvent e) + { + return predicate.test(e); + } +} diff --git a/src/main/java/net/pingex/discordbot/DiscordBot.java b/src/main/java/net/pingex/discordbot/DiscordBot.java index e1dd349..ad9f580 100644 --- a/src/main/java/net/pingex/discordbot/DiscordBot.java +++ b/src/main/java/net/pingex/discordbot/DiscordBot.java @@ -45,6 +45,7 @@ class DiscordBot // Hardcoded internal modules new InternalCommandsModule(client); + new PermissionsModule(client); if(!Configuration.exists("plugins", "enable") || Configuration.getValue("plugins", "enable").isEmpty()) { diff --git a/src/main/java/net/pingex/discordbot/PermissionsModule.java b/src/main/java/net/pingex/discordbot/PermissionsModule.java new file mode 100644 index 0000000..0fc201e --- /dev/null +++ b/src/main/java/net/pingex/discordbot/PermissionsModule.java @@ -0,0 +1,360 @@ +package net.pingex.discordbot; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import net.pingex.discordbot.json.permissions.Guild; +import sx.blah.discord.api.IDiscordClient; +import sx.blah.discord.handle.impl.events.MessageReceivedEvent; +import sx.blah.discord.handle.obj.IGuild; +import sx.blah.discord.handle.obj.IRole; +import sx.blah.discord.handle.obj.IUser; +import sx.blah.discord.util.DiscordException; +import sx.blah.discord.util.HTTP429Exception; +import sx.blah.discord.util.MissingPermissionsException; +import java.io.*; +import java.util.HashMap; +import java.util.Map; + +/** + * Permissions modules. + * @version 0.1-dev + * @author Raphael "Pingex" NAAS + */ +@Controllable(name="perm") +class PermissionsModule extends AbstractModule +{ + /** + * Main permissions table + */ + private HashMap permissions; + + /** + * Gson instance for json (de)serialisation + */ + private static Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + /** + * Current instance of this module, for easier access + */ + private static PermissionsModule INSTANCE = null; + + /** + * Get the current instance of this module + * @return This instance + */ + public static PermissionsModule getInstance() + { + return INSTANCE; + } + + /** + * Constructor doing all the basic stuff, like registering as a Listener to Discord, getting a logger, etc. + * @param client Discord Client instance used to register self. + */ + public PermissionsModule(IDiscordClient client) + { + super(client); + INSTANCE = this; + + if(!Configuration.exists("permissions", "file") || Configuration.getValue("permissions", "file").isEmpty()) + { + logger.warning("Permissions file location not specified, defaulting to `permissions.json`"); + Configuration.setValue("permissions", "file", "permissions.json"); + } + + reloadPermissions(); + } + + /** + * Gives the UID of the User passed as arguments + * @param name The current name of the user + * @param discriminator His discriminator + * @return The ID of the target user + */ + @Command + public String getUID(MessageReceivedEvent event, String name, String discriminator) + { + for(IUser i : event.getMessage().getGuild().getUsers()) + if(i.getName().equals(name) && i.getDiscriminator().equals(discriminator)) + return i.getID(); + return "User not found"; + } + + /** + * Gets caller's UID + * @return Caller's UID + */ + @Command + public String getMyUID(MessageReceivedEvent event) + { + return event.getMessage().getAuthor().getID(); + } + + /** + * Give GID of the named role + * @param name The role current name + * @return The GID of the role + */ + @Command + public String getGID(MessageReceivedEvent event, String name) + { + for(IRole i : event.getMessage().getGuild().getRoles()) + if(i.getName().equals(name)) + return i.getID(); + return "Role not found"; + } + + /** + * Dump all Roles and their GID + * Note: this commands answers the dump via PM to avoid spam and the @everyone role to be publicly displayed thus notify everyone in the guild. + * @return The whole dump, formatted as a displayable String + */ + @Command + public String dumpGID(MessageReceivedEvent event) + { + StringBuffer buffer = new StringBuffer("Dumping Roles for Guild ").append(event.getMessage().getGuild().getName()).append("\n"); + for(IRole i : event.getMessage().getGuild().getRoles()) + buffer.append(i.getName()).append("\t").append(i.getID()).append("\n"); + + // PM the IDs + // TODO: Remove when PM answers are implemented + try + { + client.getOrCreatePMChannel(event.getMessage().getAuthor()).sendMessage(buffer.toString()); + } catch (MissingPermissionsException | HTTP429Exception | DiscordException e) + { + logger.warning("Fail to send the result"); + e.printStackTrace(); + } + return "Check your PM for the command result"; + } + + /** + * Dump all Users and their UID + * Note: this commands answers the dump via PM to avoid spam. + * @return The whole dump, formatted as a displayable String + */ + @Command + public String dumpUID(MessageReceivedEvent event) + { + StringBuffer buffer = new StringBuffer("Dumping all users for Guild ").append(event.getMessage().getGuild().getName()).append("\n"); + for(IUser i : event.getMessage().getGuild().getUsers()) + buffer.append(i.getName()).append("#").append(i.getDiscriminator()).append("\t").append(i.getID()).append("\n"); + + // PM the IDs + // TODO: Remove when PM answers are implemented + try + { + client.getOrCreatePMChannel(event.getMessage().getAuthor()).sendMessage(buffer.toString()); + } catch (MissingPermissionsException | HTTP429Exception | DiscordException e) + { + logger.warning("Fail to send the result"); + e.printStackTrace(); + } + return "Check your PM for the command result"; + } + + /** + * Reload permissions from file + * @return Nothing + */ + @Command + public String reload(MessageReceivedEvent event) + { + reloadPermissions(); + return null; + } + + /** + * Save permissions to file + * @return Nothing + */ + @Command + public String save(MessageReceivedEvent event) + { + writePermissions(); + return null; + } + + /** + * Sets an user permission + * @param command The target *full* command, shorthands doesn't work + * @param user The user's UID + * @param target `true` to allow execution of the command, `false` to deny it, `null` to remove the rule + * @return + */ + @Command + public String setUser(MessageReceivedEvent event, String command, String user, Boolean target) + { + if(!permissions.containsKey(event.getMessage().getGuild().getID())) + permissions.put(event.getMessage().getGuild().getID(), new Guild(new HashMap<>())); + Guild g = permissions.get(event.getMessage().getGuild().getID()); + + if(!g.commands.containsKey(command)) + g.commands.put(command, new net.pingex.discordbot.json.permissions.Command(new HashMap<>(), new HashMap<>())); + net.pingex.discordbot.json.permissions.Command c = g.commands.get(command); + + if(target == null) c.users.remove(user); + else c.users.put(user, target); + + return "OK"; + } + + /** + * Sets a role permission + * @param command The target *full* command, shorthands doesn't work + * @param group The role's GID + * @param target `true` to allow execution of the command, `false` to deny it, `null` to remove the rule + * @return + */ + @Command + public String setRole(MessageReceivedEvent event, String command, String group, Boolean target) + { + if(!permissions.containsKey(event.getMessage().getGuild().getID())) + permissions.put(event.getMessage().getGuild().getID(), new Guild(new HashMap<>())); + Guild g = permissions.get(event.getMessage().getGuild().getID()); + + if(!g.commands.containsKey(command)) + g.commands.put(command, new net.pingex.discordbot.json.permissions.Command(new HashMap<>(), new HashMap<>())); + net.pingex.discordbot.json.permissions.Command c = g.commands.get(command); + + if(target == null) c.roles.remove(group); + else c.roles.put(group, target); + + return "OK"; + } + + /** + * Dump all permissions defined for the current guild + * @return Displayable String of the permissions + */ + @Command(shorthand = "dumpPerm") + public String dumpPermissions(MessageReceivedEvent event) + { + StringBuffer buffer = new StringBuffer("Dumping all permissions for Guild ").append(event.getMessage().getGuild().getName()).append("\n\n```"); + + if(!permissions.containsKey(event.getMessage().getGuild().getID())) + return "No permissions set for Guild"; + Guild g = permissions.get(event.getMessage().getGuild().getID()); + + for(Map.Entry i : g.commands.entrySet()) + { + buffer.append("========== COMMAND ").append(i.getKey()).append(" ==========\n"); + + // USERS + buffer.append("----- Users -----\n"); + for(Map.Entry j : i.getValue().users.entrySet()) + { + buffer.append(j.getKey()).append("\t").append(j.getValue()).append("\n"); + } + buffer.append("\n"); + + // GROUPS + buffer.append("----- Roles -----\n"); + for(Map.Entry j : i.getValue().roles.entrySet()) + { + buffer.append(j.getKey()).append("\t").append(j.getValue()).append("\n"); + } + buffer.append("\n\n"); + } + + buffer.append("```"); + return buffer.toString(); + } + + /** + * Says if the permissions overrider will allow the target user to run the target command + * @param user User name + * @param discriminator His discriminator + * @param command The target command + * @return `YES` if he can run it, `NO` if he can't, `N/A` if the decision relies to the default behavior + */ + @Command + public String canRun(MessageReceivedEvent event, String user, String discriminator, String command) + { + IUser target = null; + + for(IUser i : event.getMessage().getGuild().getUsers()) + if(i.getName().equals(user) && i.getDiscriminator().equals(discriminator)) + { + target = i; + break; + } + + if(target == null) + return "User not found in this Guild."; + + Boolean toReturn = canRun(event.getMessage().getGuild(), target, command); + return toReturn == null ? "N/A" : (toReturn ? "YES" : "NO"); + } + + /** + * Says if the permissions overrider will allow the target user to run the target command. To be run internally + * @param guild The current guild + * @param user The target user + * @param command The target command + * @return `true` is he can run the command, `false` if he can't, `null` if the overrider has nothing to say + */ + public Boolean canRun(IGuild guild, IUser user, String command) + { + // Return null if command or guild aren't mentioned in the permissions file + if(!permissions.containsKey(guild.getID()) || !permissions.get(guild.getID()).commands.containsKey(command)) return null; + net.pingex.discordbot.json.permissions.Command c = permissions.get(guild.getID()).commands.get(command); + + // User matching + if(c.users.containsKey(user.getID())) + return c.users.get(user.getID()); + + // Role matching + for(IRole i : user.getRolesForGuild(guild)) + { + if(c.roles.containsKey(i.getID())) + return c.roles.get(i.getID()); + } + return null; + } + + /** + * Reloads the permissions file. Discards the current permissions cache + */ + private void reloadPermissions() + { + logger.info("Reloading permissions"); + try + { + FileReader reader = new FileReader(Configuration.getValue("permissions", "file")); + permissions = gson.fromJson(reader, new TypeToken>(){}.getType()); + reader.close(); + } + catch (FileNotFoundException e) + { + logger.info("File doesn't exist, using default permissions"); + permissions = new HashMap<>(); + } + catch (IOException e) + { + logger.severe("I/O error while reading permissions file"); + e.printStackTrace(); + } + } + + /** + * Write the current HashMap to file. + */ + private void writePermissions() + { + logger.info("Writing permissions"); + try + { + FileWriter writer = new FileWriter(Configuration.getValue("permissions", "file")); + writer.write(gson.toJson(permissions)); + writer.close(); + } + catch (IOException e) + { + logger.severe("I/O error while writing permissions file"); + e.printStackTrace(); + } + } +} diff --git a/src/main/java/net/pingex/discordbot/json/permissions/Command.java b/src/main/java/net/pingex/discordbot/json/permissions/Command.java new file mode 100644 index 0000000..28bac2e --- /dev/null +++ b/src/main/java/net/pingex/discordbot/json/permissions/Command.java @@ -0,0 +1,25 @@ +package net.pingex.discordbot.json.permissions; + +import java.util.HashMap; + +/** + * A command's permissions list + */ +public class Command +{ + /** + * Users + */ + public HashMap users; + + /** + * Roles + */ + public HashMap roles; + + public Command(HashMap users, HashMap roles) + { + this.users = users; + this.roles = roles; + } +} diff --git a/src/main/java/net/pingex/discordbot/json/permissions/Guild.java b/src/main/java/net/pingex/discordbot/json/permissions/Guild.java new file mode 100644 index 0000000..04744ba --- /dev/null +++ b/src/main/java/net/pingex/discordbot/json/permissions/Guild.java @@ -0,0 +1,19 @@ +package net.pingex.discordbot.json.permissions; + +import java.util.HashMap; + +/** + * Json representation of a guild, contains an HashMap of commands + */ +public class Guild +{ + /** + * The list of commands which have permission overrides defined. + */ + public HashMap commands; + + public Guild(HashMap commands) + { + this.commands = commands; + } +}