diff --git a/README.md b/README.md index c77a8ee..61929b4 100644 --- a/README.md +++ b/README.md @@ -9,40 +9,53 @@

FeaturesHow To Use • - Related • - ContributingLicense

+ +
## 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(); + } +}