Skip to content

WalkerKnapp/command-framework

 
 

Repository files navigation

Command Framework

Version JavaDoc License Discord

Unit Test Coverage Badge Mutant Coverage Badge Integration Test Coverage Badge

Supported Java Versions

Supported Message Frameworks

A generic CDI-based command framework. This library requires Java 8 or newer but is fully Java 9+ compatible and can run as a proper Java module on the module path. Any arbitrary underlying message framework like a Discord library, an IRC library, or a Skype library can be used by providing an according CommandHandler implementation. You are also welcome to contribute such implementations back to the main project for all users benefit.

Table of Contents

Prerequisites

Supported Message Frameworks

The following message frameworks are currently supported:

If you want to have support for an additional, do not hesitate to open a PR or feature request issue.

Setup

Gradle

repositories { mavenCentral() }
dependencies { implementation 'net.kautler:command-framework:0.3.0' }

Maven

<dependency>
  <groupId>net.kautler</groupId>
  <artifactId>command-framework</artifactId>
  <version>0.3.0</version>
</dependency>

Manually

Download the JAR for the latest release from the Latest Release Page and include it in your project.

Usage

Message Framework

Javacord

For the Javacord support, include Javacord as implementation dependency and create a CDI producer that produces either one DiscordApi, or if you use sharding a Collection<DiscordApi> with all shards where you want commands to be handled. You should also have a disposer method that properly disconnects the produced DiscordApi instances.

Example:

@ApplicationScoped
public class JavacordProducer {
    @Inject
    private Logger logger;

    @Inject
    @Named
    private String discordToken;

    @Produces
    @ApplicationScoped
    private DiscordApi produceDiscordApi() {
        return new DiscordApiBuilder()
                .setToken(discordToken)
                .login()
                .whenComplete((discordApi, throwable) -> {
                    if (throwable != null) {
                        logger.error("Exception while logging in to Discord", throwable);
                    }
                })
                .join();
    }

    private void disposeDiscordApi(@Disposes DiscordApi discordApi) {
        discordApi.disconnect();
    }
}

Tested versions:

  • 3.0.5

JDA

For the JDA support, include JDA as implementation dependency and create a CDI producer that produces either one JDA, of if you use sharding a Collection<JDA>, a ShardManager or a Collection<ShardManager> with all shards where you want commands to be handled. You should also have a disposer method that properly shuts down the produced JDA and / or ShardManager instances.

Example:

@ApplicationScoped
public class JdaProducer {
    @Inject
    private Logger logger;

    @Inject
    @Named
    private String discordToken;

    @Produces
    @ApplicationScoped
    private JDA produceJda() {
        try {
            return new JDABuilder(discordToken)
                    .build()
                    .awaitReady();
        } catch (InterruptedException | LoginException e) {
            logger.error("Exception while logging in to Discord", e);
            return null;
        }
    }

    private void disposeJda(@Disposes JDA jda) {
        jda.shutdown();
    }
}

Tested versions:

  • 4.0.0_52

Creating Commands

Create a CDI bean that implements the Command interface.

Example:

@ApplicationScoped
public class PingCommand implements Command<Message> {
    @Override
    public void execute(Message incomingMessage, String prefix, String usedAlias, String parameterString) {
        incomingMessage.getChannel()
                .sendMessage("pong: " + parameterString)
                .exceptionally(ExceptionLogger.get());
    }
}

With everything else using the default, this is already enough to have a working ping bot. A fully self-contained example can be found at examples/simplePingBotJavacord.

To further customize the behavior of a command you can either annotate the command class or overwrite the according methods in your command implementation to replace the default implementation which evaluates the annotations. Having annotations applied and at the same time overwriting the according methods makes only sense if you want the annotations only for documentary purpose or evaluate them yourself, as the default implementations of those methods are the only places where the annotations are evaluated by default. Every other place like for example command handler call the methods on the command implementation.

Command Aliases

By overwriting the Command#getAliases() method or applying one or multiple @Alias annotations the aliases to which the command reacts can be configured. If at least one alias is configured, only the explicitly configured ones are available. If no alias is configured, the class name, stripped by Command or Cmd suffix and / or prefix and the first letter lowercased is used as default.

Asynchronous Command Execution

By overwriting the Command#isAsynchronous() method or applying the @Asynchronous annotation the command handler can be told to execute the command asynchronously.

How exactly this is implemented is up to the command handler that evaluates this command. Usually the command will be execute in some thread pool. But it would also be valid for a command handler to execute each asynchronous command execution in a new thread, so using this can add significant overhead if overused. As long as a command is not doing long-running or blocking operations it might be a good idea to not execute the command asynchronously. But if long-running or blocking operations are done in the command code directly, depending on the underlying message framework it might be a good idea to execute the command asynchronously to not block message dispatching which could introduce serious lag to the command execution.

As the command executions are potentially done on different threads, special care must be taken if the command holds state, to make sure this state is accessed in a thread-safe manner. This can of course also happen without the command being configured asynchronously if the underlying message framework dispatches message events on different threads.

Command Description

By overwriting the Command#getDescription() method or applying the @Description annotation the description of the command can be configured. Currently this description is used nowhere, but can for example be displayed in an own help command.

Command Restrictions

By overwriting the Command#getRestrictionChain() method or applying one or multiple @RestrictedTo annotations and optionally the @RestrictionPolicy annotation the restriction rules for a command can be configured. If multiple @RestrictedTo annotations are present and the default implementation of the method is used, a @RestrictionPolicy annotation that defines how the single restrictions are to be combined is mandatory. With this annotation the single restrictions can be combined in an all-of, any-of, or none-of logic.

For more complex boolean logic either overwrite the getRestrictionChain method or provide own CDI beans that implement the Restriction interface and contain the intended logic. For the latter also helpers like ChannelJavacord, RoleJavacord, ServerJavacord, UserJavacord, AllOf, AnyOf, or NoneOf can be used as super classes.

Examples:

@ApplicationScoped
public class Vampire extends UserJavacord {
    private Vampire() {
        super(341505207341023233L);
    }
}
@ApplicationScoped
public class MyFancyServer extends ServerJavacord {
    @Inject
    private MyFancyServer(@Named("myFancyServerId") long myFancyServerId) {
        super(myFancyServerId);
    }
}

Command Usage

By overwriting the Command#getUsage() method or applying the @Usage annotation the usage of the command can be configured. This usage can for example be displayed in an own help command.

When using the ParameterParser, the usage string has to follow a pre-defined format that is described at Parsing Parameters.

Parsing Parameters

There are two helpers to split the parameterString that is provided to Command#execute(...) into multiple parameters that can then be handled separately.

The first is the method Command.getParameters(...) which you give the parameter string and the maximum amount of parameters to split into. The provided string will then be split at any arbitrary amount of consecutive whitespace characters. The last element of the returned array will have all remaining text in the parameter string. If you expect exactly three parameters without whitespaces, you should set the max parameters to four, so you can easily test the length of the returned array whether too many parameters were given to the command.

The second is the ParameterParser that you can get injected into your command. For the ParameterParser to work, the usage of the command has to follow a defined syntax language. This usage syntax is then parsed and the given parameter string analysed according to the defined syntax. If the given parameter string does not adhere to the defined syntax, an IllegalArgumentException is thrown that can be caught and reported to the user giving wrong arguments. The exception message is suitable to be directly forwarded to user.

The usage string has to follow this pre-defined format:

  • Placeholders for free text without whitespaces (in the value) look like <my placeholder>
  • One placeholder for free text with whitespaces (in the value) is allowed as effectively last parameter and looks like <my placeholder...>
  • Literal parameters look like 'literal'
  • Optional parts are enclosed in square brackets like [<optional placeholder>]
  • Alternatives are enclosed in parentheses and are separated by pipe characters like ('all' | 'some' | 'none')
  • Whitespace characters between the defined tokens are optional and ignored

Examples:

  • @Usage("<coin type> <amount>")}
  • @Usage("['all'] ['exact']")}
  • @Usage("[<text...>]")}
  • @Usage("(<targetLanguage> '|' | <sourceLanguage> <targetLanguage>) <text...>")}

Warning: If you have an optional literal parameter following an optional placeholder parameter like for example [<user mention>] ['exact'] and a user invokes the command with only the parameter exact, it could fit in both parameter slots. You have to decide yourself in which slot it belongs. For cases where the literal parameter can never be meant for the placeholder, you can use ParameterParser#fixupParsedParameter(...) to correct the parameters map for the two given parameters.

Examples:

@ApplicationScoped
@Usage("<text...>")
public class PingCommand implements Command<Message> {
    @Inject
    private ParameterParser parameterParser;

    @Override
    public void execute(Message incomingMessage, String prefix, String usedAlias, String parameterString) {
        try {
            parameterParser.getParsedParameters(this, prefix, usedAlias, parameterString);
        } catch (IllegalArgumentException e) {
            incomingMessage.getChannel()
                    .sendMessage(format("%s: %s", incomingMessage.getAuthor().getDisplayName(), e.getMessage()))
                    .exceptionally(ExceptionLogger.get());
            return;
        }

        incomingMessage.getChannel()
                .sendMessage("pong: " + parameterString)
                .exceptionally(ExceptionLogger.get());
    }
}
@ApplicationScoped
@Usage("[<user mention>] ['exact']")
public class DoCommand implements Command<Message> {
    @Inject
    private ParameterParser parameterParser;

    @Override
    public void execute(Message incomingMessage, String prefix, String usedAlias, String parameterString) {
        Map<String, String> parameters;
        try {
            parameters = parameterParser.getParsedParameters(this, prefix, usedAlias, parameterString);
        } catch (IllegalArgumentException e) {
            incomingMessage.getChannel()
                    .sendMessage(format("%s: %s", incomingMessage.getAuthor().getDisplayName(), e.getMessage()))
                    .exceptionally(ExceptionLogger.get());
            return;
        }
        parameterParser.fixupParsedParameter(parameters, "user mention", "exact");
        boolean exact = parameters.containsKey("exact");
        boolean otherUserGiven = parameters.containsKey("user mention");
        String otherUserMention = parameters.get("user mention");
        // ...
    }
}

Customizing Command Prefix

A custom command prefix can be configured by providing a CDI bean that implements the PrefixProvider interface. In the implementation of the getCommandPrefix method you can determine from the message that caused the command what the command prefix should be. This way you can for example have different command prefixes for different servers your bot is serving. If no custom prefix provider is found, a default one is used, that always returns "!" as the prefix.

There are also helper classes that can be used as super classes for own prefix providers like for example a provider that returns the mention string for the bot as command prefix if Javacord is used as the underlying message framework.

Warning: The command prefix can technically be configured to be empty, but this means that every message will be checked against a regular expression and that for every non-matching message a CDI event will be sent. It is better for the performance if a command prefix is set instead of including it in the aliases directly. Due to this potential performance issue, a warning is logged each time a message is handled with an empty command prefix. If you do not care and want the warning to vanish, you have to configure your logging framework to ignore this warning, as it also costs additional performance and might hide other important log messages. ;-)

Examples:

@ApplicationScoped
public class MyPrefixProvider implements PrefixProvider<Message> {
    @Override
    public String getCommandPrefix(Message message) {
        Optional<Server> server = message.getServer();
        if (!server.isPresent()) {
            return "!";
        } else if (server.get().getId() == 12345L) {
            return "bot ";
        } else {
            return ":";
        }
    }
}
@ApplicationScoped
public class MentionPrefixProvider extends MentionPrefixProviderJavacord {
}

Customizing Alias Calculation

The alias calculation can be customized by providing a CDI bean that implements the AliasAndParameterStringTransformer interface. In the implementation of the transformAliasAndParameterString method you can determine from the message that caused the processing what the alias and parameter string should be. The transformer is called after the alias and parameter string are determined from the message using all registered aliases and before the command is resolved from the alias. If an alias was found from the registered aliases, the aliasAndParameterString parameter contains the found information. If no alias was found, the parameter will be null. The fields in the AliasAndParameterString object are always non-null.

The transformer can then either accept the found alias and parameter string by returning the argument directly, or it can determine a new alias and parameter string and return these. The return value of the transformer will be used for further processing. If the alias in the returned object is not one of the registered aliases or the transformer returns null, there will not be any command found and the respective CDI event will be fired.

Example use-cases for this are:

  • fuzzy-searching for mistyped aliases and their automatic correction (this could also be used for just a "did you mean X" response, but for that the command not found events are probably better suited)

  • having a command that forwards to one command in one channel but to another command in another channel, like !player that forwards to !mc:player in an MC channel but to !s4:player in an S4 channel

  • supporting something like !runas @other-user foo bar baz, where the transformer will transform that to alias foo and parameter string bar baz and then a custom Restriction can check whether the message author has the permissions to use !runas and then for example whether the other-user would have permissions for the foo command and only then allow it to proceed

  • forwarding to a !help command if an unknown command was issued

Example:

@ApplicationScoped
public class MyAliasAndParameterStringTransformer implements AliasAndParameterStringTransformer<Message> {
    @Override
    public AliasAndParameterString transformAliasAndParameterString(
            Message message, AliasAndParameterString aliasAndParameterString) {
        return (aliasAndParameterString == null)
                ? new AliasAndParameterString("help", "")
                : aliasAndParameterString;
    }
}

CDI Events

Handling Missing Commands

If a message starts with the configured command prefix, but does not map to an available command, the command handlers send an async CDI event that you can observe and handle to react accordingly like sending a message that a command was not found.

Example:

@ApplicationScoped
public class EventObserver {
    private void commandNotFound(@ObservesAsync CommandNotFoundEventJavacord commandNotFoundEvent) {
        commandNotFoundEvent.getMessage()
                .getChannel()
                .sendMessage(format(
                        "Command %s%s was not found!",
                        commandNotFoundEvent.getPrefix(),
                        commandNotFoundEvent.getUsedAlias()))
                .exceptionally(ExceptionLogger.get());
    }
}

Handling Disallowed Commands

If a command was found but not allowed by some restriction rules, the command handlers send an async CDI event that you can observe and handle to react accordingly like sending a message that a command was not allowed.

Example:

@ApplicationScoped
public class EventObserver {
    private void commandNotAllowed(@ObservesAsync CommandNotAllowedEventJavacord commandNotAllowedEvent) {
        commandNotAllowedEvent.getMessage()
                .getChannel()
                .sendMessage(format(
                        "Command %s%s was not allowed!",
                        commandNotAllowedEvent.getPrefix(),
                        commandNotAllowedEvent.getUsedAlias()))
                .exceptionally(ExceptionLogger.get());
    }
}

Getting the Library Version Programmatically

You are welcome to mention in some "about" command or similar that you use this library to attract other people to use it too. If you want to also mention which version you are using, you can use the getDisplayVersion method of the Version CDI bean by injecting it into your code.

Supporting other Message Frameworks

If you want to support a message framework that is not natively supported already, you need to provide a CDI bean that extends the CommandHandler class. You are also welcome to contribute back any such implementation to the library for all users benefit. You should read the JavaDoc of the CommandHandler class and have a look at any of the existing implementations to get started with writing your own implementation. Most of the common logic should be done in the CommandHandler class already and just some framework-dependent things like attaching message listeners to the underlying framework need to be done in the sub-class.

Version Numbers

Versioning of this library follows the Semantic Versioning specification.

License

Copyright 2019 Björn Kautler

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.

About

A generic CDI-based command framework

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages

  • Groovy 62.6%
  • Java 30.6%
  • Kotlin 6.6%
  • ANTLR 0.2%