Skip to content

Commit 04a9829

Browse files
committed
Allow to configure JDA slash command data for subcommands
1 parent 99e25dc commit 04a9829

5 files changed

Lines changed: 190 additions & 65 deletions

File tree

readme/README_template.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,9 @@ If your command needs parameters or you want to do other customizations of the `
302302
`SlashCommandJda#prepareSlashCommandData` method, which just returns the argument in its default implementation.
303303
In the implementation of the method you can then use the full API for JDA slash commands. For subcommands you instead
304304
overwrite the `SlashCommandJda#prepareSubcommandData` method, which has the same default implementation and customizes
305-
a `SubcommandData`.
305+
a `SubcommandData`. When having subcommands, Discord does not allow to have the top-level command as actual command
306+
itself. In this framework you can though still have that top-level command to customize the `SlashCommandData` for that
307+
parent of the subcommands, for example to set the integration types or contexts.
306308

307309
When using [command context transformers](#customizing-the-command-recognition-and-resolution-process) with slash
308310
commands, all phases before `BEFORE_COMMAND_COMPUTATION` are skipped by the command handler already.

src/main/java/net/kautler/command/util/SlashCommandBuilderProducer.java

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -103,21 +103,29 @@ Set<SlashCommandBuilder> getSlashCommandBuilders() {
103103
*/
104104
private SlashCommandBuilder createSlashCommandBuilderForCommand(
105105
String command, Map<String, List<Entry<AliasParts, SlashCommandJavacord>>> aggregationMap) {
106-
Entry<String, List<Entry<AliasParts, SlashCommandJavacord>>> firstEntry = aggregationMap
107-
.entrySet()
108-
.iterator()
109-
.next();
110-
if (firstEntry.getKey().isEmpty()) {
111-
SlashCommandJavacord slashCommand = firstEntry
112-
.getValue()
113-
.get(0)
114-
.getValue();
115-
String commandDescription = slashCommand
116-
.getDescription()
117-
.orElseThrow(() -> new IllegalStateException(format(
118-
"Descriptions are mandatory for slash commands, but command '%s' does not have one",
119-
command)));
120-
return SlashCommand.with(command, commandDescription, slashCommand.getOptions());
106+
if (aggregationMap.size() == 1) {
107+
Entry<String, List<Entry<AliasParts, SlashCommandJavacord>>> firstEntry = aggregationMap
108+
.entrySet()
109+
.iterator()
110+
.next();
111+
if (firstEntry.getKey().isEmpty()) {
112+
SlashCommandJavacord slashCommand = firstEntry
113+
.getValue()
114+
.get(0)
115+
.getValue();
116+
String commandDescription = slashCommand
117+
.getDescription()
118+
.orElseThrow(() -> new IllegalStateException(format(
119+
"Descriptions are mandatory for slash commands, but command '%s' does not have one",
120+
command)));
121+
return SlashCommand.with(command, commandDescription, slashCommand.getOptions());
122+
}
123+
}
124+
125+
if (aggregationMap.containsKey("")) {
126+
throw new IllegalStateException(format(
127+
"Top-level command '%s' cannot have subcommands / subcommand groups",
128+
command));
121129
}
122130

123131
return SlashCommand.with(

src/main/java/net/kautler/command/util/SlashCommandDatasProducer.java

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2025 Björn Kautler
2+
* Copyright 2025-2026 Björn Kautler
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -98,24 +98,36 @@ Collection<SlashCommandData> getSlashCommandDatas() {
9898
*/
9999
private SlashCommandData createSlashCommandDataForCommand(
100100
String command, Map<String, List<Entry<AliasParts, SlashCommandJda>>> aggregationMap) {
101-
Entry<String, List<Entry<AliasParts, SlashCommandJda>>> firstEntry = aggregationMap
102-
.entrySet()
103-
.iterator()
104-
.next();
105-
if (firstEntry.getKey().isEmpty()) {
106-
SlashCommandJda slashCommand = firstEntry
107-
.getValue()
108-
.get(0)
109-
.getValue();
110-
String commandDescription = slashCommand
111-
.getDescription()
112-
.orElseThrow(() -> new IllegalStateException(format(
113-
"Descriptions are mandatory for slash commands, but command '%s' does not have one",
114-
command)));
115-
return slashCommand.prepareSlashCommandData(Commands.slash(command, commandDescription));
101+
if (aggregationMap.size() == 1) {
102+
Entry<String, List<Entry<AliasParts, SlashCommandJda>>> firstEntry = aggregationMap
103+
.entrySet()
104+
.iterator()
105+
.next();
106+
if (firstEntry.getKey().isEmpty()) {
107+
SlashCommandJda slashCommand = firstEntry
108+
.getValue()
109+
.get(0)
110+
.getValue();
111+
String commandDescription = slashCommand
112+
.getDescription()
113+
.orElseThrow(() -> new IllegalStateException(format(
114+
"Descriptions are mandatory for slash commands, but command '%s' does not have one",
115+
command)));
116+
return slashCommand.prepareSlashCommandData(Commands.slash(command, commandDescription));
117+
}
116118
}
117119

118-
SlashCommandData result = Commands.slash(command, "If you see this, please inform the developer");
120+
SlashCommandData result;
121+
if (aggregationMap.containsKey("")) {
122+
SlashCommandJda slashCommand = aggregationMap
123+
.remove("")
124+
.get(0)
125+
.getValue();
126+
result = slashCommand.prepareSlashCommandData(Commands.slash(
127+
command, slashCommand.getDescription().orElse("If you see this, please inform the developer")));
128+
} else {
129+
result = Commands.slash(command, "If you see this, please inform the developer");
130+
}
119131
aggregationMap.forEach((subcommandOrGroup, aliasPartsWithCommands) -> {
120132
Entry<AliasParts, SlashCommandJda> firstAliasPartsWithCommand = aliasPartsWithCommands.get(0);
121133
AliasParts firstAliasParts = firstAliasPartsWithCommand.getKey();

src/test/groovy/net/kautler/command/util/SlashCommandBuilderProducerTest.groovy

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2022-2025 Björn Kautler
2+
* Copyright 2022-2026 Björn Kautler
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -51,6 +51,8 @@ class SlashCommandBuilderProducerTest extends Specification {
5151

5252
SlashCommandJavacord command7 = Stub()
5353

54+
SlashCommandJavacord command8 = Stub()
55+
5456
@WeldSetup
5557
def weld = WeldInitiator
5658
.from(SlashCommandBuilderProducer)
@@ -89,6 +91,11 @@ class SlashCommandBuilderProducerTest extends Specification {
8991
.scope(ApplicationScoped)
9092
.types(SlashCommandJavacord)
9193
.creating(command7)
94+
.build(),
95+
MockBean.builder()
96+
.scope(ApplicationScoped)
97+
.types(SlashCommandJavacord)
98+
.creating(command8)
9299
.build()
93100
)
94101
.inject(this)
@@ -100,34 +107,39 @@ class SlashCommandBuilderProducerTest extends Specification {
100107
def prepareCommands() {
101108
command1.with {
102109
it.aliases >> ['foo/bar1/test1']
103-
it.description >> ['The command foo/bar1/test1']
110+
it.description >> Optional.of('The command foo/bar1/test1')
104111
it.options >> [createStringOption('foo-bar1-test1-option', 'The foo/bar1/test1 option', false)]
105112
}
106113
command2.with {
107114
it.aliases >> ['foo/bar1/test2']
108-
it.description >> ['The command foo/bar1/test2']
115+
it.description >> Optional.of('The command foo/bar1/test2')
109116
}
110117
command3.with {
111118
it.aliases >> ['foo/bar2/test1']
112-
it.description >> ['The command foo/bar2/test1']
119+
it.description >> Optional.of('The command foo/bar2/test1')
113120
}
114121
command4.with {
115122
it.aliases >> ['foo/bar2/test2']
116-
it.description >> ['The command foo/bar2/test2']
123+
it.description >> Optional.of('The command foo/bar2/test2')
117124
}
118125
command5.with {
119126
it.aliases >> ['foo/test1']
120-
it.description >> ['The command foo/test1']
127+
it.description >> Optional.of('The command foo/test1')
121128
it.options >> [createStringOption('foo-test1-option', 'The foo/test1 option', true)]
122129
}
123130
command6.with {
124131
it.aliases >> ['foo/test2']
125-
it.description >> ['The command foo/test2']
132+
it.description >> Optional.of('The command foo/test2')
126133
}
127134
command7.with {
128-
it.aliases >> ['bar']
129-
it.description >> ['The command bar']
130-
it.options >> [createStringOption('bar-option', 'The bar option', false)]
135+
it.aliases >> ['bar/baz']
136+
it.description >> Optional.of('The command bar/baz')
137+
it.options >> [createStringOption('bar-baz-option', 'The bar/baz option', false)]
138+
}
139+
command8.with {
140+
it.aliases >> ['bam']
141+
it.description >> Optional.of('The command bam')
142+
it.options >> [createStringOption('bam-option', 'The bam option', false)]
131143
}
132144
}
133145

@@ -139,8 +151,8 @@ class SlashCommandBuilderProducerTest extends Specification {
139151
def slashCommandBuilders = slashCommandBuilders.get()
140152

141153
then:
142-
slashCommandBuilders.size() == 2
143-
slashCommandBuilders*.delegate*.name ==~ ['bar', 'foo']
154+
slashCommandBuilders.size() == 3
155+
slashCommandBuilders*.delegate*.name ==~ ['bar', 'bam', 'foo']
144156
with(slashCommandBuilders.find { it.delegate.name == 'foo' }) {
145157
with(it.delegate) {
146158
it.options.size() == 4
@@ -199,11 +211,27 @@ class SlashCommandBuilderProducerTest extends Specification {
199211
}
200212
with(slashCommandBuilders.find { it.delegate.name == 'bar' }) {
201213
with(it.delegate) {
202-
it.description == 'The command bar'
214+
it.options.size() == 1
215+
it.options*.name == ['baz']
216+
with(it.options.find { it.name == 'baz' }) {
217+
it.type == SUB_COMMAND
218+
it.description == 'The command bar/baz'
219+
it.options.size() == 1
220+
with(it.options.first()) {
221+
it.name == 'bar-baz-option'
222+
it.description == 'The bar/baz option'
223+
!it.required
224+
}
225+
}
226+
}
227+
}
228+
with(slashCommandBuilders.find { it.delegate.name == 'bam' }) {
229+
with(it.delegate) {
230+
it.description == 'The command bam'
203231
it.options.size() == 1
204232
with(it.options.first()) {
205-
it.name == 'bar-option'
206-
it.description == 'The bar option'
233+
it.name == 'bam-option'
234+
it.description == 'The bam option'
207235
!it.required
208236
}
209237
}
@@ -232,7 +260,8 @@ class SlashCommandBuilderProducerTest extends Specification {
232260
'command4' | '''subcommand 'foo/bar2/test2\''''
233261
'command5' | '''subcommand 'foo/test1\''''
234262
'command6' | '''subcommand 'foo/test2\''''
235-
'command7' | '''command 'bar\''''
263+
'command7' | '''subcommand 'bar/baz\''''
264+
'command8' | '''command 'bam\''''
236265
}
237266

238267
@Use(ContextualInstanceCategory)
@@ -249,4 +278,18 @@ class SlashCommandBuilderProducerTest extends Specification {
249278
ise.message == 'Alias must be one, two, or three slash-separated parts for command, ' +
250279
'''subcommand group and subcommand, but alias 'a/b/c/d' has 4 parts'''
251280
}
281+
282+
@Use(ContextualInstanceCategory)
283+
def 'should throw exception if top-level command has also subcommands'() {
284+
given:
285+
command8.aliases >> ['bar']
286+
prepareCommands()
287+
288+
when:
289+
slashCommandBuilders.get().ci()
290+
291+
then:
292+
IllegalStateException ise = thrown()
293+
ise.message == '''Top-level command 'bar' cannot have subcommands / subcommand groups'''
294+
}
252295
}

0 commit comments

Comments
 (0)