diff --git a/README.md b/README.md
index c77a8ee..61929b4 100644
--- a/README.md
+++ b/README.md
@@ -9,40 +9,53 @@
Features •
How To Use •
- Related •
- Contributing •
License
+
+
## Features
A few of the things you can do with Slash Commands:
-* some feature
-* another feature
+* Create and manage Slash Commands
+* Assign callbacks to Slash Commands events (supports per [command path](https://ci.dv8tion.net/job/JDA/javadoc/net/dv8tion/jda/api/interactions/commands/CommandInteraction.html#getCommandPath()) callbacks)
+* Assign callbacks to buttons
+* Session system for interactions (with session store)
## How to Use
```java
-public class Example {
+@Slash.Tag("slash_cmd")
+@Slash.Command(
+ name = "slash-command",
+ description = "A proof of concept Slash Command"
+)
+public final class SlashCommand {
+
+ public static void main(String[] args) throws LoginException, InterruptedException {
+ final JDA jda = JDABuilder.createDefault(...)
+ .build()
+ .awaitReady();
+ final SlashClient slash = SlashClientBuilder.create(jda)
+ .addCommand(new SlashCommand()) // register your commands
+ .build();
+
+ slash.getCommand("slash_cmd") // get a SlashCommand by it's @Slash.Tag
+ .upsertGuild(...); // upsert as a guild Slash Command
+ }
- public static void main(String[] args) {
- System.out.println("Hello World");
+ @Slash.Handler
+ public void callback(SlashCommandEvent event) {
+ event.deferReply()
+ .setContent("Hello World!")
+ .queue();
}
}
```
-*For more examples and usage, please refer to the [wiki](wiki).*
-
-## Related
-
-* some project
-* another project
-
-## Contributing
-
-Your contributions are always welcome! Please take a moment to read the [contribution guidelines](CONTRIBUTING.md) first.
+*For more examples and usage, please refer to the [playground module](playground/).*
## License
diff --git a/api/build.gradle b/api/build.gradle
index 79c0b3f..04a250e 100644
--- a/api/build.gradle
+++ b/api/build.gradle
@@ -23,5 +23,5 @@ ext.moduleName = 'api'
archivesBaseName = moduleName
dependencies {
- implementation ('com.github.DV8FromTheWorld:JDA:development-SNAPSHOT')
+ implementation ('net.dv8tion:JDA:4.3.0_277')
}
diff --git a/api/src/main/java/com/github/azzerial/slash/SlashClient.java b/api/src/main/java/com/github/azzerial/slash/SlashClient.java
new file mode 100644
index 0000000..3a4a7cd
--- /dev/null
+++ b/api/src/main/java/com/github/azzerial/slash/SlashClient.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2021 Robin Mercier
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.github.azzerial.slash;
+
+import com.github.azzerial.slash.internal.CommandRegistry;
+import com.github.azzerial.slash.internal.InteractionListener;
+import net.dv8tion.jda.api.JDA;
+import net.dv8tion.jda.api.hooks.EventListener;
+
+public final class SlashClient {
+
+ private final JDA jda;
+ private final CommandRegistry registry;
+ private final EventListener listener;
+
+ /* Constructors */
+
+ SlashClient(JDA jda, CommandRegistry registry) {
+ this.jda = jda;
+ this.registry = registry;
+ this.listener = new InteractionListener(registry);
+
+ jda.addEventListener(listener);
+ }
+
+ /* Methods */
+
+ public SlashCommand getCommand(String tag) {
+ return registry.getCommand(tag);
+ }
+}
diff --git a/api/src/main/java/com/github/azzerial/slash/SlashClientBuilder.java b/api/src/main/java/com/github/azzerial/slash/SlashClientBuilder.java
new file mode 100644
index 0000000..7eda7eb
--- /dev/null
+++ b/api/src/main/java/com/github/azzerial/slash/SlashClientBuilder.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright 2021 Robin Mercier
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.github.azzerial.slash;
+
+import com.github.azzerial.slash.internal.CommandRegistry;
+import net.dv8tion.jda.api.JDA;
+import net.dv8tion.jda.api.interactions.commands.Command;
+import net.dv8tion.jda.internal.utils.Checks;
+
+import java.util.Collection;
+import java.util.List;
+
+public final class SlashClientBuilder {
+
+ private final JDA jda;
+ private final CommandRegistry registry;
+
+ private boolean deleteUnregisteredCommands = false;
+
+ /* Constructors */
+
+ private SlashClientBuilder(JDA jda) {
+ this.jda = jda;
+ this.registry = new CommandRegistry(jda);
+ }
+
+ /* Methods */
+
+ public static SlashClientBuilder create(JDA jda) {
+ Checks.notNull(jda, "JDA");
+ return new SlashClientBuilder(jda);
+ }
+
+ public SlashClientBuilder addCommand(Object command) {
+ Checks.notNull(command, "Command");
+ registry.registerCommand(command);
+ return this;
+ }
+
+ public SlashClientBuilder addCommands(Object... commands) {
+ Checks.notNull(commands, "Commands");
+ for (Object command : commands) {
+ addCommand(command);
+ }
+ return this;
+ }
+
+ public SlashClientBuilder deleteUnregisteredCommands(boolean enabled) {
+ this.deleteUnregisteredCommands = enabled;
+ return this;
+ }
+
+ public SlashClient build() {
+ final Collection commands = registry.getCommands();
+
+ loadGlobalCommands(commands);
+ loadGuildCommands(commands);
+ return new SlashClient(jda, registry);
+ }
+
+ /* Internal */
+
+ private void loadGlobalCommands(Collection commands) {
+ final List cmds = jda.retrieveCommands().complete();
+
+ for (SlashCommand command : commands) {
+ for (Command cmd : cmds) {
+ if (cmd.getName().equals(command.getData().getName())) {
+ command.putCommand(SlashCommand.GLOBAL, cmd);
+ } else if (deleteUnregisteredCommands) {
+ cmd.delete().queue();
+ }
+ }
+ }
+ }
+
+ private void loadGuildCommands(Collection commands) {
+ jda.getGuilds()
+ .forEach(guild -> {
+ final List cmds = guild.retrieveCommands().complete();
+
+ for (SlashCommand command : commands) {
+ for (Command cmd : cmds) {
+ if (cmd.getName().equals(command.getData().getName())) {
+ command.putCommand(guild.getIdLong(), cmd);
+ } else if (deleteUnregisteredCommands) {
+ cmd.delete().queue();
+ }
+ }
+ }
+ });
+ }
+}
diff --git a/api/src/main/java/com/github/azzerial/slash/SlashCommand.java b/api/src/main/java/com/github/azzerial/slash/SlashCommand.java
new file mode 100644
index 0000000..cde9b25
--- /dev/null
+++ b/api/src/main/java/com/github/azzerial/slash/SlashCommand.java
@@ -0,0 +1,230 @@
+/*
+ * Copyright 2021 Robin Mercier
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.github.azzerial.slash;
+
+import net.dv8tion.jda.api.JDA;
+import net.dv8tion.jda.api.entities.Guild;
+import net.dv8tion.jda.api.interactions.commands.Command;
+import net.dv8tion.jda.api.interactions.commands.build.CommandData;
+import net.dv8tion.jda.api.interactions.commands.privileges.CommandPrivilege;
+import net.dv8tion.jda.api.requests.RestAction;
+import net.dv8tion.jda.internal.utils.Checks;
+
+import java.lang.reflect.Method;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+
+public final class SlashCommand {
+
+ public static final long GLOBAL = -1L;
+
+ private final JDA jda;
+ private final String tag;
+ private final CommandData data;
+ private final Object obj;
+ private final Map handlers;
+ private final Map> instances = new HashMap<>();
+
+ /* Constructors */
+
+ public SlashCommand(JDA jda, String tag, CommandData data, Object obj, Map handlers) {
+ this.jda = jda;
+ this.tag = tag;
+ this.data = data;
+ this.obj = obj;
+ this.handlers = handlers;
+ }
+
+ /* Getters & Setters */
+
+ public String getTag() {
+ return tag;
+ }
+
+ public CommandData getData() {
+ return data;
+ }
+
+ public Object getObjectInstance() {
+ return obj;
+ }
+
+ public List getCommandIds() {
+ return instances.values().stream()
+ .map(AtomicReference::get)
+ .map(Command::getIdLong)
+ .collect(Collectors.toList());
+ }
+
+ public Map getHandlers() {
+ return handlers;
+ }
+
+ public synchronized void putCommand(long id, Command command) {
+ instances.put(id, new AtomicReference<>(command));
+ }
+
+ /* Methods */
+
+ public synchronized SlashCommand deleteGlobal() {
+ if (instances.containsKey(GLOBAL)) {
+ final AtomicReference command = instances.get(GLOBAL);
+
+ command.get().delete().queue();
+ instances.remove(GLOBAL);
+ }
+ return this;
+ }
+
+ public SlashCommand deleteGuild(long id) {
+ return deleteGuild(jda.getGuildById(id));
+ }
+
+ public SlashCommand deleteGuild(String id) {
+ return deleteGuild(jda.getGuildById(id));
+ }
+
+ public synchronized SlashCommand deleteGuild(Guild guild) {
+ Checks.notNull(guild, "Guild");
+ if (instances.containsKey(guild.getIdLong())) {
+ final AtomicReference command = instances.get(guild.getIdLong());
+
+ command.get().delete().queue();
+ instances.remove(guild.getIdLong());
+ }
+ return this;
+ }
+
+ public RestAction> retrieveGlobalPrivileges(long id) {
+ return retrieveGlobalPrivileges(jda.getGuildById(id));
+ }
+
+ public RestAction> retrieveGlobalPrivileges(String id) {
+ return retrieveGlobalPrivileges(jda.getGuildById(id));
+ }
+
+ public synchronized RestAction> retrieveGlobalPrivileges(Guild guild) {
+ Checks.notNull(guild, "Guild");
+ return instances.containsKey(GLOBAL) ?
+ instances.get(GLOBAL).get().retrievePrivileges(guild) :
+ null;
+ }
+
+ public RestAction> retrieveGuildPrivileges(long id) {
+ return retrieveGuildPrivileges(jda.getGuildById(id));
+ }
+
+ public RestAction> retrieveGuildPrivileges(String id) {
+ return retrieveGuildPrivileges(jda.getGuildById(id));
+ }
+
+ public synchronized RestAction> retrieveGuildPrivileges(Guild guild) {
+ Checks.notNull(guild, "Guild");
+ return instances.containsKey(guild.getIdLong()) ?
+ instances.get(guild.getIdLong()).get().retrievePrivileges(guild) :
+ null;
+ }
+
+ public RestAction> updateGlobalPrivileges(long id, CommandPrivilege... privileges) {
+ return updateGlobalPrivileges(jda.getGuildById(id), privileges);
+ }
+
+ public RestAction> updateGlobalPrivileges(String id, CommandPrivilege... privileges) {
+ return updateGlobalPrivileges(jda.getGuildById(id), privileges);
+ }
+
+ public synchronized RestAction> updateGlobalPrivileges(Guild guild, CommandPrivilege... privileges) {
+ Checks.notNull(guild, "Guild");
+ Checks.noneNull(privileges, "CommandPrivileges");
+ return instances.containsKey(GLOBAL) ?
+ instances.get(GLOBAL).get().updatePrivileges(guild, privileges) :
+ null;
+ }
+
+ public RestAction> updateGlobalPrivileges(long id, Collection privileges) {
+ return updateGlobalPrivileges(jda.getGuildById(id), privileges);
+ }
+
+ public RestAction> updateGlobalPrivileges(String id, Collection privileges) {
+ return updateGlobalPrivileges(jda.getGuildById(id), privileges);
+ }
+
+ public synchronized RestAction> updateGlobalPrivileges(Guild guild, Collection privileges) {
+ Checks.notNull(guild, "Guild");
+ Checks.noneNull(privileges, "CommandPrivileges");
+ return instances.containsKey(GLOBAL) ?
+ instances.get(GLOBAL).get().updatePrivileges(guild, privileges) :
+ null;
+ }
+
+ public RestAction> updateGuildPrivileges(long id, CommandPrivilege... privileges) {
+ return updateGuildPrivileges(jda.getGuildById(id), privileges);
+ }
+
+ public RestAction> updateGuildPrivileges(String id, CommandPrivilege... privileges) {
+ return updateGuildPrivileges(jda.getGuildById(id), privileges);
+ }
+
+ public synchronized RestAction> updateGuildPrivileges(Guild guild, CommandPrivilege... privileges) {
+ Checks.notNull(guild, "Guild");
+ Checks.noneNull(privileges, "CommandPrivileges");
+ return instances.containsKey(guild.getIdLong()) ?
+ instances.get(guild.getIdLong()).get().updatePrivileges(guild, privileges) :
+ null;
+ }
+
+ public RestAction> updateGuildPrivileges(long id, Collection privileges) {
+ return updateGuildPrivileges(jda.getGuildById(id), privileges);
+ }
+
+ public RestAction> updateGuildPrivileges(String id, Collection privileges) {
+ return updateGuildPrivileges(jda.getGuildById(id), privileges);
+ }
+
+ public synchronized RestAction> updateGuildPrivileges(Guild guild, Collection privileges) {
+ Checks.notNull(guild, "Guild");
+ Checks.noneNull(privileges, "CommandPrivileges");
+ return instances.containsKey(guild.getIdLong()) ?
+ instances.get(guild.getIdLong()).get().updatePrivileges(guild, privileges) :
+ null;
+ }
+
+ public synchronized SlashCommand upsertGlobal() {
+ jda.upsertCommand(data)
+ .queue(command -> instances.put(GLOBAL, new AtomicReference<>(command)));
+ return this;
+ }
+
+ public SlashCommand upsertGuild(long id) {
+ return upsertGuild(jda.getGuildById(id));
+ }
+
+ public SlashCommand upsertGuild(String id) {
+ return upsertGuild(jda.getGuildById(id));
+ }
+
+ public synchronized SlashCommand upsertGuild(Guild guild) {
+ Checks.notNull(guild, "Guild");
+ guild.upsertCommand(data)
+ .queue(command -> instances.put(guild.getIdLong(), new AtomicReference<>(command)));
+ return this;
+ }
+}
diff --git a/api/src/main/java/com/github/azzerial/slash/annotations/Choice.java b/api/src/main/java/com/github/azzerial/slash/annotations/Choice.java
new file mode 100644
index 0000000..60b9d4b
--- /dev/null
+++ b/api/src/main/java/com/github/azzerial/slash/annotations/Choice.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2021 Robin Mercier
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.github.azzerial.slash.annotations;
+
+import java.lang.annotation.*;
+
+/**
+ * This annotation represents a choice of a Slash Command option.
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.ANNOTATION_TYPE)
+public @interface Choice {
+
+ /** The name of the choice, cannot be empty or longer than {@code 100} characters.*/
+ String name();
+ /** The value of the choice, either an {@code int} or a {@code String}. In the case the value is a {@code String}, it cannot be empty or longer than {@code 100} characters.*/
+ String value();
+}
diff --git a/api/src/main/java/com/github/azzerial/slash/annotations/Option.java b/api/src/main/java/com/github/azzerial/slash/annotations/Option.java
new file mode 100644
index 0000000..ec434d4
--- /dev/null
+++ b/api/src/main/java/com/github/azzerial/slash/annotations/Option.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2021 Robin Mercier
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.github.azzerial.slash.annotations;
+
+import java.lang.annotation.*;
+
+/**
+ * This annotation represents a Slash Command option.
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.ANNOTATION_TYPE)
+public @interface Option {
+
+ /** The name of the option, must match {@code [a-z0-9-]{1,32}}.*/
+ String name();
+ /** The description of the option, cannot be empty or longer than {@code 100} characters.*/
+ String description();
+ /** The type of the option. */
+ OptionType type();
+ /** The requirement of this option, whether the option is required or optional. */
+ boolean required() default false;
+ /** The choices list of the option. */
+ Choice[] choices() default {};
+}
diff --git a/api/src/main/java/com/github/azzerial/slash/annotations/OptionType.java b/api/src/main/java/com/github/azzerial/slash/annotations/OptionType.java
new file mode 100644
index 0000000..0ccecf4
--- /dev/null
+++ b/api/src/main/java/com/github/azzerial/slash/annotations/OptionType.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2021 Robin Mercier
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.github.azzerial.slash.annotations;
+
+/**
+ * This enum represents the type of a Slash Command option.
+ */
+public enum OptionType {
+ STRING,
+ INTEGER,
+ BOOLEAN,
+ USER,
+ CHANNEL,
+ ROLE,
+ MENTIONABLE
+}
diff --git a/api/src/main/java/com/github/azzerial/slash/annotations/Slash.java b/api/src/main/java/com/github/azzerial/slash/annotations/Slash.java
new file mode 100644
index 0000000..82fa436
--- /dev/null
+++ b/api/src/main/java/com/github/azzerial/slash/annotations/Slash.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2021 Robin Mercier
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.github.azzerial.slash.annotations;
+
+import java.lang.annotation.*;
+
+/**
+ * The annotation holding the main annotations of the Slash Commands library.
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target({})
+public @interface Slash {
+
+ /**
+ * This annotation labels a class as a Slash Command.
+ */
+ @Documented
+ @Retention(RetentionPolicy.RUNTIME)
+ @Target(ElementType.TYPE)
+ @interface Command {
+
+ /** The name of the Slash Command, must match {@code [a-z0-9-]{1,32}}.*/
+ String name();
+ /** The description of the Slash Command, cannot be empty or longer than {@code 100} characters.*/
+ String description();
+ /** The option list of the Slash Command. */
+ Option[] options() default {};
+ /** The subcommand list of the Slash Command. */
+ Subcommand[] subcommands() default {};
+ /** The subcommand group list of the Slash Command. */
+ SubcommandGroup[] subcommandGroups() default {};
+ /** The default permission of the Slash Command, whether the command is enabled by default when the app is added to a guild. */
+ boolean enabled() default true;
+ }
+
+ /**
+ * This annotation labels a method as a Slash Command button handler.
+ */
+ @Documented
+ @Retention(RetentionPolicy.RUNTIME)
+ @Target(ElementType.METHOD)
+ @interface Button {
+
+ /** The tag of the button. */
+ String value();
+ }
+
+ /**
+ * This annotation labels a method as a Slash Command handler.
+ */
+ @Documented
+ @Retention(RetentionPolicy.RUNTIME)
+ @Target(ElementType.METHOD)
+ @interface Handler {
+
+ /**
+ * The path to the handler.
+ */
+ String value() default "";
+ }
+
+ /**
+ * This annotations assigns an identification tag to a Slash Command for registration purposes.
+ */
+ @Documented
+ @Retention(RetentionPolicy.RUNTIME)
+ @Target(ElementType.TYPE)
+ @interface Tag {
+
+ /**
+ * The value of the tag.
+ */
+ String value();
+ }
+}
diff --git a/api/src/main/java/com/github/azzerial/slash/annotations/Subcommand.java b/api/src/main/java/com/github/azzerial/slash/annotations/Subcommand.java
new file mode 100644
index 0000000..20f4303
--- /dev/null
+++ b/api/src/main/java/com/github/azzerial/slash/annotations/Subcommand.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2021 Robin Mercier
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.github.azzerial.slash.annotations;
+
+import java.lang.annotation.*;
+
+/**
+ * This annotation represents a Slash Command subcommand.
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.ANNOTATION_TYPE)
+public @interface Subcommand {
+
+ /** The name of the subcommand, must match {@code [a-z0-9-]{1,32}}.*/
+ String name();
+ /** The description of the subcommand, cannot be empty or longer than {@code 100} characters.*/
+ String description();
+ /** The option list of the subcommand. */
+ Option[] options() default {};
+}
diff --git a/api/src/main/java/com/github/azzerial/slash/annotations/SubcommandGroup.java b/api/src/main/java/com/github/azzerial/slash/annotations/SubcommandGroup.java
new file mode 100644
index 0000000..bbafc2d
--- /dev/null
+++ b/api/src/main/java/com/github/azzerial/slash/annotations/SubcommandGroup.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2021 Robin Mercier
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.github.azzerial.slash.annotations;
+
+import java.lang.annotation.*;
+
+/**
+ * This annotation represents a Slash Command subcommand group.
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.ANNOTATION_TYPE)
+public @interface SubcommandGroup {
+
+ /** The name of the subcommand group, must match {@code [a-z0-9-]{1,32}}.*/
+ String name();
+ /** The description of the subcommand group, cannot be empty or longer than {@code 100} characters.*/
+ String description();
+ /** The subcommand list of the subcommand group. */
+ Subcommand[] subcommands() default {};
+}
diff --git a/api/src/main/java/com/github/azzerial/slash/components/SlashButton.java b/api/src/main/java/com/github/azzerial/slash/components/SlashButton.java
new file mode 100644
index 0000000..35d5295
--- /dev/null
+++ b/api/src/main/java/com/github/azzerial/slash/components/SlashButton.java
@@ -0,0 +1,264 @@
+/*
+ * Copyright 2021 Robin Mercier
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.github.azzerial.slash.components;
+
+import com.github.azzerial.slash.internal.ButtonRegistry;
+import net.dv8tion.jda.api.entities.Emoji;
+import net.dv8tion.jda.api.interactions.components.ButtonStyle;
+import net.dv8tion.jda.api.interactions.components.Component;
+import net.dv8tion.jda.api.utils.data.DataObject;
+import net.dv8tion.jda.internal.utils.Checks;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+public final class SlashButton implements Component {
+
+ private String tag;
+ private String data;
+ private String label;
+ private ButtonStyle style;
+ private String url;
+ private boolean disabled;
+ private Emoji emoji;
+
+ /* Constructors */
+
+ SlashButton(String tag, String data, String label, ButtonStyle style, boolean disabled, Emoji emoji) {
+ this(tag, data, label, style, null, disabled, emoji);
+ }
+
+ SlashButton(String tag, String data, String label, ButtonStyle style, String url, boolean disabled, Emoji emoji) {
+ this.tag = tag;
+ this.data = data;
+ this.label = label;
+ this.style = style;
+ this.url = url;
+ this.disabled = disabled;
+ this.emoji = emoji;
+ }
+
+ /* Getters & Setters */
+
+ @NotNull
+ @Override
+ public Type getType() {
+ return Type.BUTTON;
+ }
+
+ @Nullable
+ @Override
+ public String getId() {
+ return tag == null ? null : ButtonRegistry.getInstance().createButtonId(tag, data);
+ }
+
+ public String getTag() {
+ return tag;
+ }
+
+ public SlashButton withTag(String tag) {
+ Checks.notEmpty(tag, "Tag");
+ this.tag = tag;
+ return this;
+ }
+
+ public String getData() {
+ return data;
+ }
+
+ public SlashButton withData(String data) {
+ Checks.notEmpty(data, "Data");
+ Checks.notLonger(data, 100 - ButtonRegistry.CODE_LENGTH, "Data");
+ this.data = data;
+ return this;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public SlashButton withLabel(String label) {
+ Checks.notEmpty(label, "Label");
+ Checks.notLonger(label, 80, "Label");
+ this.label = label;
+ return this;
+ }
+
+ public ButtonStyle getStyle() {
+ return style;
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ public SlashButton withUrl(String url) {
+ Checks.notEmpty(url, "Url");
+ Checks.notLonger(url, 512, "Url");
+ this.url = url;
+ this.style = ButtonStyle.LINK;
+ return this;
+ }
+
+ public Emoji getEmoji() {
+ return emoji;
+ }
+
+ public SlashButton withEmoji(Emoji emoji) {
+ this.emoji = emoji;
+ return this;
+ }
+
+ public boolean isDisabled() {
+ return disabled;
+ }
+
+ public SlashButton asDisabled() {
+ this.disabled = true;
+ return this;
+ }
+
+ public SlashButton asEnabled() {
+ this.disabled = false;
+ return this;
+ }
+
+ public SlashButton withDisabled(boolean disabled) {
+ this.disabled = disabled;
+ return this;
+ }
+
+ /* Methods */
+
+ public static SlashButton primary(String tag, String label) {
+ Checks.notEmpty(tag, "Tag");
+ Checks.notEmpty(label, "Label");
+ Checks.notLonger(label, 80, "Label");
+ return new SlashButton(tag, null, label, ButtonStyle.PRIMARY, false, null);
+ }
+
+ public static SlashButton primary(String tag, Emoji emoji) {
+ Checks.notEmpty(tag, "Tag");
+ Checks.notNull(emoji, "Emoji");
+ return new SlashButton(tag, null, "", ButtonStyle.PRIMARY, false, emoji);
+ }
+
+ public static SlashButton secondary(String tag, String label) {
+ Checks.notEmpty(tag, "Tag");
+ Checks.notEmpty(label, "Label");
+ Checks.notLonger(label, 80, "Label");
+ return new SlashButton(tag, null, label, ButtonStyle.SECONDARY, false, null);
+ }
+
+ public static SlashButton secondary(String tag, Emoji emoji) {
+ Checks.notEmpty(tag, "Tag");
+ Checks.notNull(emoji, "Emoji");
+ return new SlashButton(tag, null, "", ButtonStyle.SECONDARY, false, emoji);
+ }
+
+ public static SlashButton success(String tag, String label) {
+ Checks.notEmpty(tag, "Tag");
+ Checks.notEmpty(label, "Label");
+ Checks.notLonger(label, 80, "Label");
+ return new SlashButton(tag, null, label, ButtonStyle.SUCCESS, false, null);
+ }
+
+ public static SlashButton success(String tag, Emoji emoji) {
+ Checks.notEmpty(tag, "Tag");
+ Checks.notNull(emoji, "Emoji");
+ return new SlashButton(tag, null, "", ButtonStyle.SUCCESS, false, emoji);
+ }
+
+ public static SlashButton danger(String tag, String label) {
+ Checks.notEmpty(tag, "Tag");
+ Checks.notEmpty(label, "Label");
+ Checks.notLonger(label, 80, "Label");
+ return new SlashButton(tag, null, label, ButtonStyle.DANGER, false, null);
+ }
+
+ public static SlashButton danger(String tag, Emoji emoji) {
+ Checks.notEmpty(tag, "Tag");
+ Checks.notNull(emoji, "Emoji");
+ return new SlashButton(tag, null, "", ButtonStyle.DANGER, false, emoji);
+ }
+
+ public static SlashButton link(String url, String label) {
+ Checks.notEmpty(url, "Url");
+ Checks.notEmpty(label, "Label");
+ Checks.notLonger(url, 512, "Url");
+ Checks.notLonger(label, 80, "Label");
+ return new SlashButton(null, null, label, ButtonStyle.LINK, url, false, null);
+ }
+
+ public static SlashButton link(String url, Emoji emoji) {
+ Checks.notEmpty(url, "Url");
+ Checks.notLonger(url, 512, "Url");
+ Checks.notNull(emoji, "Emoji");
+ return new SlashButton(null, null, "", ButtonStyle.LINK, url, false, null);
+ }
+
+ public static SlashButton of(ButtonStyle style, String tagOrUrl, String label) {
+ Checks.check(style != ButtonStyle.UNKNOWN, "The button style cannot be UNKNOWN!");
+ Checks.notNull(style, "Style");
+ if (style == ButtonStyle.LINK) {
+ return link(tagOrUrl, label);
+ }
+ Checks.notNull(label, "Label");
+ Checks.notLonger(label, 80, "Label");
+ Checks.notEmpty(tagOrUrl, "Tag");
+ return new SlashButton(tagOrUrl, null, label, style, false, null);
+ }
+
+ public static SlashButton of(ButtonStyle style, String tagOrUrl, Emoji emoji) {
+ Checks.check(style != ButtonStyle.UNKNOWN, "The button style cannot be UNKNOWN!");
+ Checks.notNull(style, "Style");
+ if (style == ButtonStyle.LINK) {
+ return link(tagOrUrl, emoji);
+ }
+ Checks.notNull(emoji, "Emoji");
+ Checks.notEmpty(tagOrUrl, "Tag");
+ return new SlashButton(tagOrUrl, null, "", style, false, emoji);
+ }
+
+ public static SlashButton of(ButtonStyle style, String tagOrUrl, String label, Emoji emoji) {
+ if (label != null) {
+ return of(style, tagOrUrl, label).withEmoji(emoji);
+ } else if (emoji != null) {
+ return of(style, tagOrUrl, emoji);
+ }
+ throw new IllegalArgumentException("Cannot build a button without a label and emoji. At least one has to be provided as non-null.");
+ }
+
+ @NotNull
+ @Override
+ public DataObject toData() {
+ final DataObject json = DataObject.empty();
+
+ json.put("type", 2);
+ json.put("label", label);
+ json.put("style", style.getKey());
+ json.put("disabled", disabled);
+ if (emoji != null) {
+ json.put("emoji", emoji);
+ }
+ if (url != null) {
+ json.put("url", url);
+ } else {
+ json.put("custom_id", getId());
+ }
+ return json;
+ }
+}
diff --git a/api/src/main/java/com/github/azzerial/slash/internal/AnnotationCompiler.java b/api/src/main/java/com/github/azzerial/slash/internal/AnnotationCompiler.java
new file mode 100644
index 0000000..1d38080
--- /dev/null
+++ b/api/src/main/java/com/github/azzerial/slash/internal/AnnotationCompiler.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright 2021 Robin Mercier
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.github.azzerial.slash.internal;
+
+import com.github.azzerial.slash.annotations.Option;
+import com.github.azzerial.slash.annotations.Slash;
+import com.github.azzerial.slash.annotations.Subcommand;
+import com.github.azzerial.slash.annotations.SubcommandGroup;
+import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
+import net.dv8tion.jda.api.interactions.commands.Command;
+import net.dv8tion.jda.api.interactions.commands.OptionType;
+import net.dv8tion.jda.api.interactions.commands.build.CommandData;
+import net.dv8tion.jda.api.interactions.commands.build.OptionData;
+import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
+import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
+import net.dv8tion.jda.internal.utils.Checks;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.*;
+import java.util.stream.Collectors;
+
+public final class AnnotationCompiler {
+
+ /* Methods */
+
+ public CommandData compileCommand(Slash.Command command) {
+ final CommandData data = new CommandData(command.name(), command.description());
+
+ data.setDefaultEnabled(command.enabled());
+ if (command.subcommands().length != 0) {
+ data.addSubcommands(
+ Arrays.stream(command.subcommands())
+ .map(this::compileSubcommand)
+ .collect(Collectors.toList())
+ );
+ }
+ if (command.subcommandGroups().length != 0) {
+ data.addSubcommandGroups(
+ Arrays.stream(command.subcommandGroups())
+ .map(this::compileSubcommandGroup)
+ .collect(Collectors.toList())
+ );
+ }
+ if (command.options().length != 0) {
+ data.addOptions(
+ Arrays.stream(command.options())
+ .map(this::compileOption)
+ .collect(Collectors.toList())
+ );
+ }
+ return data;
+ }
+
+ public Map compileHandlers(Class> cls, CommandData data) {
+ final List methods = Arrays.stream(cls.getDeclaredMethods())
+ .filter(method ->
+ (method.getModifiers() & (Modifier.PROTECTED | Modifier.PRIVATE)) == 0
+ && method.isAnnotationPresent(Slash.Handler.class)
+ && method.getParameterCount() == 1
+ && method.getParameterTypes()[0] == SlashCommandEvent.class
+ )
+ .collect(Collectors.toList());
+ final Map> handlers = buildHandlersMap(methods);
+
+ checkHandlers(data, handlers);
+ return new HashMap() {{
+ handlers.forEach((k, v) -> put(
+ data.getName() + (k.isEmpty() ? "" : "/" + k),
+ v.get(0)
+ ));
+ }};
+ }
+
+ /* Internal */
+
+ private OptionData compileOption(Option option) {
+ Checks.notNull(option, "Option");
+ final OptionData data = new OptionData(
+ OptionType.fromKey(option.type().ordinal() + 3),
+ option.name(),
+ option.description(),
+ option.required()
+ );
+
+ if (option.type() == com.github.azzerial.slash.annotations.OptionType.STRING
+ || option.type() == com.github.azzerial.slash.annotations.OptionType.INTEGER) {
+ data.addChoices(
+ Arrays.stream(option.choices())
+ .map(choice -> {
+ switch (option.type()) {
+ case STRING: return new Command.Choice(choice.name(), choice.value());
+ case INTEGER: return new Command.Choice(choice.name(), Integer.parseInt(choice.value()));
+ default: return null;
+ }
+ })
+ .collect(Collectors.toList())
+ );
+ }
+ return data;
+ }
+
+ private SubcommandData compileSubcommand(Subcommand subcommand) {
+ Checks.notNull(subcommand, "Subcommand");
+ return new SubcommandData(subcommand.name(), subcommand.description())
+ .addOptions(
+ Arrays.stream(subcommand.options())
+ .map(this::compileOption)
+ .collect(Collectors.toList())
+ );
+ }
+
+ private SubcommandGroupData compileSubcommandGroup(SubcommandGroup subcommandGroup) {
+ Checks.notNull(subcommandGroup, "SubcommandGroup");
+ return new SubcommandGroupData(subcommandGroup.name(), subcommandGroup.description())
+ .addSubcommands(
+ Arrays.stream(subcommandGroup.subcommands())
+ .map(this::compileSubcommand)
+ .collect(Collectors.toList())
+ );
+ }
+
+ private Map> buildHandlersMap(List methods) {
+ final Map> handlers = new HashMap<>();
+
+ for (Method method : methods) {
+ final Slash.Handler handler = method.getAnnotation(Slash.Handler.class);
+
+ if (!handlers.containsKey(handler.value())) {
+ handlers.put(handler.value(), new LinkedList<>());
+ }
+ handlers.get(handler.value()).add(method);
+ }
+ return handlers;
+ }
+
+ private void checkHandlers(CommandData data, Map> handlers) {
+ final Set paths = new HashSet<>();
+
+ if (!data.getSubcommandGroups().isEmpty()) {
+ for (SubcommandGroupData subcommandGroup : data.getSubcommandGroups()) {
+ for (SubcommandData subcommand : subcommandGroup.getSubcommands()) {
+ paths.add(subcommandGroup.getName() + "/" + subcommand.getName());
+ }
+ }
+ } else if (!data.getSubcommands().isEmpty()) {
+ for (SubcommandData subcommand : data.getSubcommands()) {
+ paths.add(subcommand.getName());
+ }
+ } else {
+ paths.add("");
+ }
+
+ for (String path : handlers.keySet()) {
+ final List methods = handlers.get(path);
+
+ if (!paths.contains(path)) {
+ throw new IllegalArgumentException("Could not find the '" + path + "' command path in '" + data.getName() + "'!");
+ }
+ if (methods.size() != 1) {
+ throw new IllegalArgumentException("Multiple handlers were declared for the '" + path + "' command path in '" + data.getName() + "'!");
+ }
+ }
+ }
+
+}
diff --git a/api/src/main/java/com/github/azzerial/slash/internal/ButtonCallback.java b/api/src/main/java/com/github/azzerial/slash/internal/ButtonCallback.java
new file mode 100644
index 0000000..2e0ef4e
--- /dev/null
+++ b/api/src/main/java/com/github/azzerial/slash/internal/ButtonCallback.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2021 Robin Mercier
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.github.azzerial.slash.internal;
+
+import java.lang.reflect.Method;
+
+public final class ButtonCallback {
+
+ private final Object obj;
+ private final Method method;
+
+ /* Constructors */
+
+ ButtonCallback(Object obj, Method method) {
+ this.obj = obj;
+ this.method = method;
+ }
+
+ /* Getters & Setters */
+
+ public Object getObjectInstance() {
+ return obj;
+ }
+
+ public Method getMethod() {
+ return method;
+ }
+}
diff --git a/api/src/main/java/com/github/azzerial/slash/internal/ButtonRegistry.java b/api/src/main/java/com/github/azzerial/slash/internal/ButtonRegistry.java
new file mode 100644
index 0000000..ac4dd36
--- /dev/null
+++ b/api/src/main/java/com/github/azzerial/slash/internal/ButtonRegistry.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2021 Robin Mercier
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.github.azzerial.slash.internal;
+
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+
+public final class ButtonRegistry {
+
+ public static final int CODE_LENGTH = 4;
+ public static final int ID_BASE = 32;
+ private static final ButtonRegistry INSTANCE = new ButtonRegistry();
+
+ private final List codes;
+ private final Map mappings = new HashMap<>();
+
+ /* Constructors */
+
+ private ButtonRegistry() {
+ this.codes = new LinkedList<>();
+
+ codes.add(null);
+ }
+
+ /* Getters & Setters */
+
+ public static ButtonRegistry getInstance() {
+ return INSTANCE;
+ }
+
+ public String createButtonId(String tag, String data) {
+ final int code = codes.indexOf(tag);
+ return String.format(
+ "%-" + CODE_LENGTH + "." + CODE_LENGTH + "s" +
+ "%." + (100 - CODE_LENGTH) + "s",
+ Integer.toUnsignedString(code == -1 ? 0 : code, ID_BASE),
+ data == null ? "" : data
+ ).trim();
+ }
+
+ public ButtonCallback getButtonCallback(String id) {
+ final int code = Integer.parseUnsignedInt(parseCode(id), ID_BASE);
+ final String tag = codes.get(code);
+ return mappings.get(tag);
+ }
+
+ /* Methods */
+
+ public void registerButton(String tag, ButtonCallback callback) {
+ if (!codes.contains(tag)) {
+ codes.add(tag);
+ mappings.put(tag, callback);
+ }
+ }
+
+ /* Internal */
+
+ private String parseCode(String s) {
+ return s == null || s.isEmpty() ?
+ null :
+ s.substring(0, Math.min(CODE_LENGTH, s.length())).trim();
+ }
+
+ private String parseData(String s) {
+ return s == null || s.length() <= CODE_LENGTH ?
+ null :
+ s.substring(CODE_LENGTH);
+ }
+}
diff --git a/api/src/main/java/com/github/azzerial/slash/internal/CommandRegistry.java b/api/src/main/java/com/github/azzerial/slash/internal/CommandRegistry.java
new file mode 100644
index 0000000..191bef4
--- /dev/null
+++ b/api/src/main/java/com/github/azzerial/slash/internal/CommandRegistry.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2021 Robin Mercier
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.github.azzerial.slash.internal;
+
+import com.github.azzerial.slash.SlashCommand;
+import com.github.azzerial.slash.annotations.Slash;
+import net.dv8tion.jda.api.JDA;
+import net.dv8tion.jda.api.events.interaction.ButtonClickEvent;
+import net.dv8tion.jda.api.interactions.commands.build.CommandData;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.*;
+
+public final class CommandRegistry {
+
+ private final JDA jda;
+ private final Map registry = new HashMap<>();
+ private final AnnotationCompiler annotationCompiler = new AnnotationCompiler();
+
+ /* Constructors */
+
+ public CommandRegistry(JDA jda) {
+ this.jda = jda;
+ }
+
+ /* Getters & Setters */
+
+ public SlashCommand getCommand(String tag) {
+ return registry.get(tag);
+ }
+
+ public SlashCommand getCommandById(long id) {
+ return registry.values().stream()
+ .filter(command -> command.getCommandIds().contains(id))
+ .findFirst()
+ .orElse(null);
+ }
+
+ public Collection getCommands() {
+ return registry.values();
+ }
+
+ /* Methods */
+
+ public SlashCommand registerCommand(Object obj) {
+ final SlashCommand command = compileCommand(obj);
+
+ registerButtons(obj);
+ registry.put(command.getTag(), command);
+ return command;
+ }
+
+ /* Internal */
+
+ private SlashCommand compileCommand(Object obj) {
+ final Class> cls = obj.getClass();
+ final Slash.Tag tag = cls.getAnnotation(Slash.Tag.class);
+ final Slash.Command command = cls.getAnnotation(Slash.Command.class);
+
+ if (tag == null) {
+ throw new IllegalArgumentException("Provided " + cls.getSimpleName() + ".class is not annotated with @Slash.Tag!");
+ }
+ if (command == null) {
+ throw new IllegalArgumentException("Provided " + cls.getSimpleName() + ".class is not annotated with @Slash.Command!");
+ }
+ if (registry.containsKey(tag.value())) {
+ throw new IllegalArgumentException("Tried to register " + cls.getSimpleName() + ".class, but the '" + tag.value() + "' tag was already in use!");
+ }
+
+ final CommandData data = annotationCompiler.compileCommand(command);
+ final Map handlers = annotationCompiler.compileHandlers(cls, data);
+ return new SlashCommand(jda, tag.value(), data, obj, handlers);
+ }
+
+ private void registerButtons(Object obj) {
+ final Class> cls = obj.getClass();
+
+ Arrays.stream(cls.getDeclaredMethods())
+ .filter(method ->
+ (method.getModifiers() & (Modifier.PROTECTED | Modifier.PRIVATE)) == 0
+ && method.isAnnotationPresent(Slash.Button.class)
+ && method.getParameterCount() == 1
+ && method.getParameterTypes()[0] == ButtonClickEvent.class
+ )
+ .sorted(Comparator.comparing(Method::getName))
+ .forEach(method -> {
+ final String tag = method.getAnnotation(Slash.Button.class).value();
+
+ if (!tag.isEmpty()) {
+ ButtonRegistry.getInstance().registerButton(tag, new ButtonCallback(obj, method));
+ }
+ });
+ }
+}
diff --git a/api/src/main/java/com/github/azzerial/slash/internal/InteractionListener.java b/api/src/main/java/com/github/azzerial/slash/internal/InteractionListener.java
new file mode 100644
index 0000000..8a055a8
--- /dev/null
+++ b/api/src/main/java/com/github/azzerial/slash/internal/InteractionListener.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2021 Robin Mercier
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.github.azzerial.slash.internal;
+
+import com.github.azzerial.slash.SlashCommand;
+import net.dv8tion.jda.api.events.interaction.ButtonClickEvent;
+import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
+import net.dv8tion.jda.api.hooks.ListenerAdapter;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+public final class InteractionListener extends ListenerAdapter {
+
+ private final CommandRegistry registry;
+
+ /* Constructors */
+
+ public InteractionListener(CommandRegistry registry) {
+ this.registry = registry;
+ }
+
+ /* Methods */
+
+ @Override
+ public void onSlashCommand(SlashCommandEvent event) {
+ if (event.getUser().isBot()) {
+ return;
+ }
+
+ final SlashCommand command = registry.getCommandById(event.getCommandIdLong());
+
+ if (command != null) {
+ final Method method = command.getHandlers().get(event.getCommandPath());
+
+ if (method != null) {
+ try {
+ method.invoke(command.getObjectInstance(), event);
+ } catch (IllegalAccessException | InvocationTargetException ignored) {}
+ }
+ }
+ }
+
+ @Override
+ public void onButtonClick(ButtonClickEvent event) {
+ if (event.getUser().isBot()) {
+ return;
+ }
+
+ final ButtonCallback button = ButtonRegistry.getInstance().getButtonCallback(event.getComponentId());
+
+ if (button != null) {
+ final Method method = button.getMethod();
+
+ try {
+ method.invoke(button.getObjectInstance(), event);
+ } catch (IllegalAccessException | InvocationTargetException ignored) {}
+ }
+ }
+}
diff --git a/api/src/main/java/com/github/azzerial/slash/util/Buffer.java b/api/src/main/java/com/github/azzerial/slash/util/Buffer.java
new file mode 100644
index 0000000..17ed6a6
--- /dev/null
+++ b/api/src/main/java/com/github/azzerial/slash/util/Buffer.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright 2021 Robin Mercier
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.github.azzerial.slash.util;
+
+import com.github.azzerial.slash.internal.ButtonRegistry;
+import net.dv8tion.jda.internal.utils.Checks;
+
+public final class Buffer {
+
+ /* Nested Classes */
+
+ public static final class Reader {
+
+ private final String buffer;
+
+ private int i = ButtonRegistry.CODE_LENGTH;
+
+ /* Constructors */
+
+ public Reader(String buffer) {
+ Checks.notNull(buffer, "Buffer");
+ if (buffer.length() <= i) {
+ throw new IllegalArgumentException("The buffer is invalid!");
+ }
+ this.buffer = buffer;
+ }
+
+ /* Methods */
+
+ public static Reader of(String buffer) {
+ return new Reader(buffer);
+ }
+
+ public Data read(int size) {
+ final String s = buffer.substring(i, Math.min(buffer.length(), i + size));
+
+ i += s.length();
+ return new Data(s);
+ }
+
+ /* Nested Classes */
+
+ public static final class Data {
+
+ private final String s;
+
+ /* Constructors */
+
+ private Data(String s) {
+ this.s = s;
+ }
+
+ /* Methods */
+
+ public String asString() {
+ return s.trim();
+ }
+
+ public boolean asBoolean() {
+ return asInt(2) == 1;
+ }
+
+ public int asInt() {
+ return Integer.parseInt(s.trim());
+ }
+
+ public int asInt(int base) {
+ return Integer.parseInt(s.trim(), base);
+ }
+
+ public int asUnsignedInt() {
+ return Integer.parseUnsignedInt(s.trim());
+ }
+
+ public int asUnsignedInt(int base) {
+ return Integer.parseUnsignedInt(s.trim(), base);
+ }
+
+ public long asLong() {
+ return Long.parseLong(s.trim());
+ }
+
+ public long asLong(int base) {
+ return Long.parseLong(s.trim(), base);
+ }
+
+ public long asUnsignedLong() {
+ return Long.parseUnsignedLong(s.trim());
+ }
+
+ public long asUnsignedLong(int base) {
+ return Long.parseUnsignedLong(s.trim(), base);
+ }
+
+ public boolean isEmpty() {
+ return s.isEmpty();
+ }
+ }
+ }
+
+ public static final class Writer {
+
+ private final StringBuilder sb = new StringBuilder();
+
+ /* Constructors */
+
+ public Writer() {}
+
+ /* Methods */
+
+ public static Writer create() {
+ return new Writer();
+ }
+
+ public Writer write(int size, String s) {
+ if (sb.length() + size > 100 - ButtonRegistry.CODE_LENGTH) {
+ throw new OutOfMemoryError("Required allocation size is greater than the available one!");
+ }
+ sb.append(String.format("%-" + size + "." + size + "s", s));
+ return this;
+ }
+
+ public Writer write(boolean b) {
+ return write(1, b ? "1" : "0");
+ }
+
+ public Writer write(int size, int i) {
+ return write(size, i, 10);
+ }
+
+ public Writer write(int size, int i, int base) {
+ return write(size, Integer.toString(i, base));
+ }
+
+ public Writer write(int size, long l) {
+ return write(size, l, 10);
+ }
+
+ public Writer write(int size, long l, int base) {
+ return write(size, Long.toString(l, base));
+ }
+
+ @Override
+ public String toString() {
+ return sb.toString();
+ }
+ }
+}
diff --git a/api/src/main/java/com/github/azzerial/slash/util/Session.java b/api/src/main/java/com/github/azzerial/slash/util/Session.java
new file mode 100644
index 0000000..d2926f6
--- /dev/null
+++ b/api/src/main/java/com/github/azzerial/slash/util/Session.java
@@ -0,0 +1,193 @@
+/*
+ * Copyright 2021 Robin Mercier
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.github.azzerial.slash.util;
+
+import net.dv8tion.jda.api.interactions.InteractionHook;
+import net.dv8tion.jda.api.utils.data.DataObject;
+import net.dv8tion.jda.internal.utils.Checks;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+
+import static com.github.azzerial.slash.internal.ButtonRegistry.CODE_LENGTH;
+
+public final class Session extends DataObject {
+
+ public static final long DEFAULT_TIMEOUT = 60_000L;
+ public static final TimeUnit DEFAULT_TIMEOUT_UNIT = TimeUnit.MILLISECONDS;
+
+ private static final String UUID_REGEX = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
+
+ private static final ScheduledExecutorService threadpool = Executors.newSingleThreadScheduledExecutor();
+ private static final Map sessions = new HashMap<>();
+
+ private final UUID uuid;
+ private final Map storage = new HashMap<>();
+
+ private final long timeout;
+ private final TimeUnit unit;
+ private final InteractionHook hook;
+ private final BiConsumer action;
+ private ScheduledFuture> thread;
+
+ /* Constructors */
+
+ private Session(Session session, Map data) {
+ this(session.uuid, data, session.timeout, session.unit, session.hook, session.action);
+ }
+
+ private Session(UUID uuid, Map data, long timeout, TimeUnit unit, InteractionHook hook, BiConsumer action) {
+ super(data);
+ Checks.notNull(uuid, "UUID");
+ Checks.notNegative(timeout, "Timeout");
+ this.uuid = uuid;
+ this.timeout = timeout;
+ this.unit = unit;
+ this.hook = hook;
+ this.action = action;
+
+ startTimeoutThread();
+ }
+
+ /* Getters & Setters */
+
+ public String getUuid() {
+ return uuid.toString();
+ }
+
+ /* Methods */
+
+ public static Session create() {
+ return create(DEFAULT_TIMEOUT, DEFAULT_TIMEOUT_UNIT, null, null);
+ }
+
+ public static Session create(long timeout, TimeUnit unit) {
+ return create(timeout, unit, null, null);
+ }
+
+ public static Session create(long timeout, TimeUnit unit, InteractionHook hook, BiConsumer action) {
+ Checks.positive(timeout, "Timeout");
+ Checks.notNull(unit, "Unit");
+ final UUID uuid = UUID.randomUUID();
+ final Session session = new Session(uuid, new HashMap<>(), timeout, unit, hook, action);
+
+ sessions.put(uuid, session);
+ return session;
+ }
+
+ public static Session load(String id) {
+ return get(id, false);
+ }
+
+ public static Session renew(String id) {
+ return get(id, true);
+ }
+
+ @NotNull
+ @Override
+ public Session remove(@NotNull String key) {
+ super.remove(key);
+ return this;
+ }
+
+ @NotNull
+ @Override
+ public Session putNull(@NotNull String key) {
+ super.putNull(key);
+ return this;
+ }
+
+ @NotNull
+ @Override
+ public Session put(@NotNull String key, @Nullable Object value) {
+ super.put(key, value);
+ return this;
+ }
+
+ public String store(Consumer consumer) {
+ final UUID uuid = UUID.randomUUID();
+ final DataObject data = DataObject.fromJson(toJson());
+
+ consumer.accept(data);
+ storage.put(uuid, data);
+ return getUuid() + uuid;
+ }
+
+ /* Internal */
+
+ private static Session get(String id, boolean renew) {
+ Checks.notNull(id, "Id");
+ if (id.length() != CODE_LENGTH + 36 && id.length() != CODE_LENGTH + 72) {
+ throw new IllegalArgumentException("The id is invalid!");
+ }
+
+ final String sessionStr = id.substring(CODE_LENGTH, CODE_LENGTH + 36);
+ final String storageStr = id.length() == CODE_LENGTH + 72 ?
+ id.substring(CODE_LENGTH +36, CODE_LENGTH + 72) :
+ null;
+
+ if (!sessionStr.matches(UUID_REGEX) || (storageStr != null && !storageStr.matches(UUID_REGEX))) {
+ throw new IllegalArgumentException("The id is invalid!");
+ }
+
+ final UUID sessionUuid = UUID.fromString(sessionStr);
+ Session session = sessions.remove(sessionUuid);
+
+ if (session == null) {
+ return null;
+ } else if (session.thread != null && !session.thread.isDone()) {
+ session.thread.cancel(true);
+ }
+
+ final UUID storageUuid = storageStr != null ? UUID.fromString(storageStr) : null;
+ final DataObject data = session.storage.get(storageUuid);
+
+ if (data != null && !data.keys().isEmpty()) {
+ session = new Session(session, data.toMap());
+ }
+ if (renew) {
+ session.startTimeoutThread();
+ sessions.put(session.uuid, session);
+ }
+ return session;
+ }
+
+ private void startTimeoutThread() {
+ if (timeout > 0 && unit != null) {
+ if (thread != null && !thread.isDone()) {
+ return;
+ }
+
+ this.thread = threadpool.schedule(() -> {
+ if (sessions.remove(uuid) != null && hook != null && action != null) {
+ action.accept(hook, this);
+ }
+ }, timeout, unit);
+ } else {
+ this.thread = null;
+ }
+ }
+}
diff --git a/build.gradle b/build.gradle
index 980ed49..115817d 100644
--- a/build.gradle
+++ b/build.gradle
@@ -26,7 +26,6 @@ subprojects {
repositories {
mavenCentral()
- maven { url 'https://jitpack.io' }
maven { url 'https://m2.dv8tion.net/releases' }
}
diff --git a/playground/build.gradle b/playground/build.gradle
index d3ab61b..a51ac39 100644
--- a/playground/build.gradle
+++ b/playground/build.gradle
@@ -21,7 +21,7 @@ plugins {
dependencies {
implementation project(':api')
implementation ('ch.qos.logback:logback-classic:1.2.3')
- implementation ('com.github.DV8FromTheWorld:JDA:development-SNAPSHOT')
+ implementation ('net.dv8tion:JDA:4.3.0_277')
}
java {
@@ -33,8 +33,10 @@ java {
tasks.named('run') {
if (file('.env').exists()) {
file('.env').eachLine {
- def (key, value) = it.tokenize('=')
- environment(key, value)
+ if (it.matches('[^#][^=]+=.*')) {
+ def (key, value) = it.tokenize('=')
+ environment(key, value)
+ }
}
}
}
diff --git a/playground/sample.env b/playground/sample.env
index 36724e2..8600687 100644
--- a/playground/sample.env
+++ b/playground/sample.env
@@ -1,4 +1,7 @@
# Save this file as ".env" after editing!
# Discord bot token
-token=
\ No newline at end of file
+token=
+
+# SlashClient development guild id
+guild_id=
\ No newline at end of file
diff --git a/playground/src/main/java/com/github/azzerial/slash/playground/Main.java b/playground/src/main/java/com/github/azzerial/slash/playground/Main.java
index 49fb88e..313a378 100644
--- a/playground/src/main/java/com/github/azzerial/slash/playground/Main.java
+++ b/playground/src/main/java/com/github/azzerial/slash/playground/Main.java
@@ -16,6 +16,9 @@
package com.github.azzerial.slash.playground;
+import com.github.azzerial.slash.SlashClient;
+import com.github.azzerial.slash.SlashClientBuilder;
+import com.github.azzerial.slash.playground.commands.PingCommand;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.requests.GatewayIntent;
@@ -34,6 +37,7 @@ public final class Main {
public static void main(String[] args) {
// get the .env config variables
final String token = System.getenv("token");
+ final String guildId = System.getenv("guild_id");
try {
// create the JDA instance
@@ -41,6 +45,14 @@ public static void main(String[] args) {
.createLight(token, EnumSet.noneOf(GatewayIntent.class))
.build()
.awaitReady();
+ // create the SlashClient instance
+ final SlashClient slash = SlashClientBuilder
+ .create(jda)
+ .addCommand(new PingCommand()) // register the ping command
+ .build();
+
+ // add the command to a guild if not already added
+ slash.getCommand("ping").upsertGuild(guildId);
} catch (LoginException e) {
logger.error("The bot token was invalid!");
} catch (InterruptedException e) {
diff --git a/playground/src/main/java/com/github/azzerial/slash/playground/commands/PingCommand.java b/playground/src/main/java/com/github/azzerial/slash/playground/commands/PingCommand.java
new file mode 100644
index 0000000..f8622c2
--- /dev/null
+++ b/playground/src/main/java/com/github/azzerial/slash/playground/commands/PingCommand.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2021 Robin Mercier
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.github.azzerial.slash.playground.commands;
+
+import com.github.azzerial.slash.annotations.Slash;
+import com.github.azzerial.slash.components.SlashButton;
+import net.dv8tion.jda.api.EmbedBuilder;
+import net.dv8tion.jda.api.entities.MessageEmbed;
+import net.dv8tion.jda.api.events.interaction.ButtonClickEvent;
+import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
+
+@Slash.Tag("ping")
+@Slash.Command(
+ name = "ping",
+ description = "Check the current latency"
+)
+public final class PingCommand {
+
+ private final MessageEmbed initialMessage = buildPingMessage("...");
+
+ /* Methods */
+
+ @Slash.Handler
+ public void ping(SlashCommandEvent event) {
+ final long time = System.currentTimeMillis();
+
+ event.deferReply(true)
+ .addEmbeds(initialMessage)
+ .addActionRow(
+ SlashButton.primary("ping.refresh", "Refresh")
+ )
+ .flatMap(v -> {
+ final long latency = System.currentTimeMillis() - time;
+ final String ms = Long.toUnsignedString(latency);
+ final MessageEmbed message = buildPingMessage(ms);
+ return event.getHook().editOriginalEmbeds(message);
+ })
+ .queue();
+ }
+
+ @Slash.Button("ping.refresh")
+ public void onRefresh(ButtonClickEvent event) {
+ final long time = System.currentTimeMillis();
+
+ event.deferEdit()
+ .flatMap(v -> {
+ final long latency = System.currentTimeMillis() - time;
+ final String ms = Long.toUnsignedString(latency);
+ final MessageEmbed message = buildPingMessage(ms);
+ return event.getHook().editOriginalEmbeds(message);
+ })
+ .queue();
+ }
+
+ /* Internal */
+
+ private MessageEmbed buildPingMessage(String ms) {
+ final EmbedBuilder builder = new EmbedBuilder();
+
+ builder.setDescription("**Ping:** `" + ms + "`ms");
+ return builder.build();
+ }
+}