├── .gitignore ├── LICENSE ├── README.md ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── settings.gradle.kts └── src └── main └── kotlin └── dev └── ruffrick └── jda └── commands ├── CommandRegistry.kt ├── CommandRegistryBuilder.kt ├── SlashCommand.kt ├── annotations ├── Button.kt ├── Command.kt ├── GuildOnly.kt ├── Option.kt ├── Permissions.kt └── Subcommand.kt ├── event ├── ButtonInteractionListener.kt └── SlashCommandInteractionListener.kt └── mapping └── Mapper.kt /.gitignore: -------------------------------------------------------------------------------- 1 | # Project exclude paths 2 | /.gradle/ 3 | /.idea/ 4 | /build/ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 ruffrick 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [1]: https://github.com/DV8FromTheWorld/JDA/ 2 | 3 | [2]: https://github.com/ruffrick/jda-kotlinx 4 | 5 | # JDA-Commands 6 | 7 | JDA-Commands is an easy to use, annotation-based slash command framework for the popular Discord API wrapper [JDA][1]. 8 | This bundles [jda-kotlinx][2] to add coroutine support for suspending event listeners. 9 | 10 | ## Examples 11 | 12 | ### Commands 13 | 14 | To create a command, simply create a class extending `SlashCommand` and annotate it as `@Command`. 15 | 16 | ```kotlin 17 | @Command 18 | class PingCommand : SlashCommand() { 19 | @Command 20 | fun ping(event: SlashCommandEvent) { // This is registered as `/ping` 21 | event.reply("Pong!").queue() 22 | } 23 | } 24 | ``` 25 | 26 | If no name is specified in the `@Command` annotation, the command name is parsed from the class name by removing the 27 | `Command` suffix. To create a single top-level command, annotate any function in the class as `@Command`. No additional 28 | arguments are required in the annotation. 29 | 30 | To create more complex commands with subcommands and subcommand groups, use the `@Subcommand` annotation. To group 31 | multiple subcommands, specify the `group` parameter in the annotation and add a `groupDescription` to any one of the 32 | annotations belonging to the same group. 33 | 34 | ```kotlin 35 | @Command 36 | class HelloCommand : SlashCommand() { 37 | @Subcommand 38 | fun world(event: SlashCommandEvent) { // This is registered as `/hello world` 39 | event.reply("Hello world!").queue() 40 | } 41 | 42 | @Subcommand 43 | fun user(event: SlashCommandEvent) { // This is registered as `/hello user` 44 | event.reply("Hello ${event.user.name}!").queue() 45 | } 46 | } 47 | ``` 48 | 49 | Command options can be added by adding parameters to the function and annotating them as `@Option`. Nullable parameters 50 | will be registered as optional arguments, non-nullable parameters as required arguments. Allowed types are `String`, 51 | `Long`, `Boolean`, `User`, `GuildChannel`, `Role` and `Double`. 52 | 53 | ```kotlin 54 | @Command 55 | class GreetCommand : SlashCommand() { 56 | @Command 57 | fun greet( 58 | event: SlashCommandEvent, 59 | @Option user: User, 60 | @Option message: String? 61 | ) { // This is registered as `/greet ` 62 | if (message == null) { 63 | event.reply("Greetings, ${user.name}!") 64 | } else { 65 | event.reply("Greetings, ${user.name}! $message!") 66 | }.queue() 67 | } 68 | } 69 | ``` 70 | 71 | To register the commands, use `CommandRegistryBuilder` to build a command registry and then call `updateCommands()` on 72 | that instance to register the commands for a `ShardManager`, `JDA` or `Guild` instance. 73 | 74 | ```kotlin 75 | fun main() { 76 | val jda = JDABuilder.createLight("token") 77 | .useSuspendEventManager() 78 | .build() 79 | 80 | val commandRegistry = CommandRegistryBuilder() 81 | .addCommands(PingCommand(), HelloCommand(), GreetCommand()) 82 | .build() 83 | 84 | commandRegistry.updateCommands(jda) 85 | } 86 | ``` 87 | 88 | ### Buttons 89 | 90 | This library also adds functionality to handle buttons using annotated functions. Simply add a button to a message using 91 | the functions provided by `SlashCommand` and create a `@Button` function for each ID used. 92 | 93 | ```kotlin 94 | @Command 95 | class MoodCommand : SlashCommand() { 96 | @Command 97 | fun mood(event: SlashCommandEvent) { 98 | event.reply("How are you?").addActionRow( 99 | success("good", "Good"), 100 | danger("bad", "Bad") 101 | ).queue() 102 | } 103 | 104 | @Button 105 | fun good(event: ButtonClickEvent) { 106 | event.editMessage("That's good to hear!").setActionRows().queue() 107 | } 108 | 109 | @Button 110 | fun bad(event: ButtonClickEvent) { 111 | event.editMessage("Oh no! Here, have some \uD83C\uDF68!").setActionRows().queue() 112 | } 113 | } 114 | ``` 115 | 116 | ### Type Mapping 117 | 118 | You can add support for custom types for both commands and buttons.* 119 | 120 | ```kotlin 121 | class DurationMapper : Mapper { 122 | private val pattern = Regex("^(\\d+)([dhms])\$").toPattern() 123 | 124 | override suspend fun transform(value: String): Duration { 125 | val matcher = pattern.matcher(value.lowercase()) 126 | require(matcher.matches()) { "Please enter a duration, e. g. `1h` or `3d`!" } 127 | val count = matcher.group(1).toInt() 128 | require(count > 0) { "Your duration can't be less than one!" } 129 | 130 | return when (matcher.group(2)) { 131 | "d" -> Duration.days(count) 132 | "h" -> Duration.hours(count) 133 | "m" -> Duration.minutes(count) 134 | "s" -> Duration.seconds(count) 135 | else -> throw IllegalStateException("How did we get here?") 136 | } 137 | } 138 | } 139 | ``` 140 | 141 | If an `IllegalArgumentException` is thrown while mapping, it's message will be replied to the event and the 142 | command/button handler function will not be executed. 143 | 144 | Allowed input types are `String`, `Long`, `Boolean`, `User`, `GuildChannel`, `Role` and `Double` as well as 145 | `SlashCommandEvent` and `ButtonClickEvent`. When using a non-standard type for an option within a command or button 146 | function, the option type will be the input type of the first mapper found to produce that type. 147 | 148 | ```kotlin 149 | @Command 150 | fun command( 151 | context: CommandContext, // Requires a Mapper 152 | @Option duration: Duration // Requires a Mapper 153 | ) { 154 | // ... 155 | } 156 | 157 | @Button 158 | fun button( 159 | context: ButtonContext, // Requires a Mapper 160 | long: Long // Requires a Mapper 161 | ) { 162 | // ... 163 | } 164 | ``` 165 | 166 | ## Download 167 | 168 | ### Gradle 169 | 170 | ```gradle 171 | repositories { 172 | mavenCentral() 173 | maven("https://m2.dv8tion.net/releases") 174 | maven("https://jitpack.io/") 175 | } 176 | 177 | dependencies { 178 | implementation("net.dv8tion:JDA:${JDA_VERSION}") 179 | implementation("com.github.ruffrick:jda-commands:${COMMIT}") 180 | } 181 | ``` 182 | 183 | ### Maven 184 | 185 | ```maven 186 | 187 | dv8tion 188 | m2-dv8tion 189 | https://m2.dv8tion.net/releases 190 | 191 | 192 | jitpack 193 | jitpack 194 | https://jitpack.io/ 195 | 196 | ``` 197 | 198 | ```maven 199 | 200 | net.dv8tion 201 | JDA 202 | $JDA_VERSION 203 | 204 | 205 | com.github.ruffrick 206 | jda-commands 207 | $COMMIT 208 | 209 | ``` 210 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | // https://plugins.gradle.org/plugin/org.jetbrains.kotlin.jvm 5 | kotlin("jvm") version "1.9.0" 6 | 7 | `maven-publish` 8 | } 9 | 10 | group = "dev.ruffrick" 11 | version = "0.1" 12 | 13 | repositories { 14 | mavenCentral() 15 | maven("https://jitpack.io") 16 | } 17 | 18 | dependencies { 19 | // https://mvnrepository.com/artifact/org.jetbrains.kotlin/kotlin-reflect 20 | implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.0") 21 | 22 | // https://mvnrepository.com/artifact/net.dv8tion/JDA 23 | compileOnly("net.dv8tion:JDA:5.0.0-beta.12") 24 | 25 | // https://github.com/ruffrick/jda-kotlinx 26 | api("com.github.ruffrick:jda-kotlinx:b101ea7") 27 | } 28 | 29 | tasks.withType { 30 | kotlinOptions.jvmTarget = "1.8" 31 | } 32 | 33 | val sourcesJar = task("sourcesJar") { 34 | from(sourceSets["main"].allSource) 35 | archiveClassifier.set("sources") 36 | } 37 | 38 | tasks { 39 | build { 40 | dependsOn(sourcesJar) 41 | dependsOn(jar) 42 | } 43 | } 44 | 45 | publishing { 46 | publications { 47 | create("release") { 48 | groupId = project.group as String 49 | artifactId = project.name 50 | version = project.version as String 51 | 52 | from(components["kotlin"]) 53 | artifact(sourcesJar) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruffrick/jda-commands/eb0a3a78190aecac37ed44b1ee0852bf320decff/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "jda-commands" 2 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/ruffrick/jda/commands/CommandRegistry.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("unused") 2 | 3 | package dev.ruffrick.jda.commands 4 | 5 | import dev.ruffrick.jda.commands.annotations.* 6 | import dev.ruffrick.jda.commands.event.ButtonInteractionListener 7 | import dev.ruffrick.jda.commands.event.SlashCommandInteractionListener 8 | import dev.ruffrick.jda.commands.mapping.Mapper 9 | import dev.ruffrick.jda.kotlinx.LogFactory 10 | import net.dv8tion.jda.api.JDA 11 | import net.dv8tion.jda.api.entities.* 12 | import net.dv8tion.jda.api.entities.channel.Channel 13 | import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent 14 | import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent 15 | import net.dv8tion.jda.api.interactions.commands.Command.Choice 16 | import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions 17 | import net.dv8tion.jda.api.interactions.commands.OptionType 18 | import net.dv8tion.jda.api.interactions.commands.build.Commands 19 | import net.dv8tion.jda.api.interactions.commands.build.OptionData 20 | import net.dv8tion.jda.api.interactions.commands.build.SubcommandData 21 | import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData 22 | import net.dv8tion.jda.api.sharding.ShardManager 23 | import kotlin.reflect.KClass 24 | import kotlin.reflect.KFunction 25 | import kotlin.reflect.full.findAnnotation 26 | import kotlin.reflect.full.memberFunctions 27 | 28 | class CommandRegistry( 29 | val commands: List, 30 | val mappers: List>, 31 | ) { 32 | private val log by LogFactory 33 | private val optionTypes = mapOf( 34 | String::class to OptionType.STRING, 35 | Long::class to OptionType.INTEGER, 36 | Boolean::class to OptionType.BOOLEAN, 37 | User::class to OptionType.USER, 38 | Channel::class to OptionType.CHANNEL, 39 | Role::class to OptionType.ROLE, 40 | IMentionable::class to OptionType.MENTIONABLE, 41 | Double::class to OptionType.NUMBER, 42 | ) 43 | 44 | internal val commandFunctions = mutableMapOf, List>>() 45 | internal val buttonFunctions = mutableMapOf>() 46 | 47 | init { 48 | for (command in commands) { 49 | val commandAnnotation = command::class.findAnnotation() ?: continue 50 | 51 | val commandName = commandAnnotation.name.ifEmpty { 52 | command::class.simpleName!!.removeSuffix("Command").lowercase() 53 | } 54 | val commandData = Commands.slash(commandName, commandAnnotation.description.ifEmpty { commandName }) 55 | val subcommandGroups = mutableListOf() 56 | 57 | for (function in command::class.memberFunctions) { 58 | function.findAnnotation()?.let { 59 | val options = parseOptions(function) 60 | commandData.addOptions(options) 61 | commandFunctions[commandName] = function to options.map { it.name } 62 | } ?: function.findAnnotation()?.let { subcommand -> 63 | val options = parseOptions(function) 64 | val subcommandName = subcommand.name.ifEmpty { function.name.lowercase() } 65 | if (subcommand.group.isNotEmpty()) { 66 | val subcommandGroupName = subcommand.group 67 | val subcommandData = SubcommandData(subcommandName, 68 | subcommand.description.ifEmpty { subcommandName }).addOptions(options) 69 | subcommandGroups.firstOrNull { subcommandGroupData -> 70 | subcommandGroupData.name == subcommandGroupName 71 | }?.addSubcommands(subcommandData) ?: subcommandGroups.add( 72 | SubcommandGroupData( 73 | subcommand.group, 74 | subcommand.groupDescription.ifEmpty { subcommandName }).addSubcommands(subcommandData) 75 | ) 76 | commandFunctions["$commandName.$subcommandGroupName.$subcommandName"] = 77 | function to options.map { it.name } 78 | } else { 79 | commandData.addSubcommands( 80 | SubcommandData( 81 | subcommandName, 82 | subcommand.description.ifEmpty { subcommandName }).addOptions(options) 83 | ) 84 | commandFunctions["$commandName.$subcommandName"] = function to options.map { it.name } 85 | } 86 | } ?: function.findAnnotation