Skip to content

Commit 281d118

Browse files
authoredDec 1, 2023
fix(computeDifference): correctly check for extra properties and split up compute difference (#695)
1 parent 403ddea commit 281d118

16 files changed

+805
-656
lines changed
 

‎src/lib/utils/application-commands/ApplicationCommandRegistry.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ import type {
2121
} from 'discord.js';
2222
import { InternalRegistryAPIType, RegisterBehavior } from '../../types/Enums';
2323
import { allGuildIdsToFetchCommandsFor, getDefaultBehaviorWhenNotIdentical, getDefaultGuildIds } from './ApplicationCommandRegistries';
24-
import { getCommandDifferences, getCommandDifferencesFast, type CommandDifference } from './computeDifferences';
24+
import type { CommandDifference } from './compute-differences/_shared';
25+
import { getCommandDifferences, getCommandDifferencesFast } from './computeDifferences';
2526
import { convertApplicationCommandToApiData, normalizeChatInputCommand, normalizeContextMenuCommand } from './normalizeInputs';
2627

2728
export class ApplicationCommandRegistry {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {
2+
ApplicationCommandOptionType,
3+
ApplicationCommandType,
4+
type APIApplicationCommandIntegerOption,
5+
type APIApplicationCommandNumberOption,
6+
type APIApplicationCommandOption,
7+
type APIApplicationCommandStringOption,
8+
type APIApplicationCommandSubcommandGroupOption,
9+
type APIApplicationCommandSubcommandOption
10+
} from 'discord-api-types/v10';
11+
12+
export const optionTypeToPrettyName = new Map<ApplicationCommandOptionType, string>([
13+
[ApplicationCommandOptionType.Subcommand, 'subcommand'],
14+
[ApplicationCommandOptionType.SubcommandGroup, 'subcommand group'],
15+
[ApplicationCommandOptionType.String, 'string option'],
16+
[ApplicationCommandOptionType.Integer, 'integer option'],
17+
[ApplicationCommandOptionType.Boolean, 'boolean option'],
18+
[ApplicationCommandOptionType.User, 'user option'],
19+
[ApplicationCommandOptionType.Channel, 'channel option'],
20+
[ApplicationCommandOptionType.Role, 'role option'],
21+
[ApplicationCommandOptionType.Mentionable, 'mentionable option'],
22+
[ApplicationCommandOptionType.Number, 'number option'],
23+
[ApplicationCommandOptionType.Attachment, 'attachment option']
24+
]);
25+
26+
export const contextMenuTypes = [ApplicationCommandType.Message, ApplicationCommandType.User];
27+
export const subcommandTypes = [ApplicationCommandOptionType.SubcommandGroup, ApplicationCommandOptionType.Subcommand];
28+
29+
export type APIApplicationCommandSubcommandTypes = APIApplicationCommandSubcommandOption | APIApplicationCommandSubcommandGroupOption;
30+
export type APIApplicationCommandNumericTypes = APIApplicationCommandIntegerOption | APIApplicationCommandNumberOption;
31+
export type APIApplicationCommandChoosableAndAutocompletableTypes = APIApplicationCommandNumericTypes | APIApplicationCommandStringOption;
32+
33+
export function hasMinMaxValueSupport(option: APIApplicationCommandOption): option is APIApplicationCommandNumericTypes {
34+
return [ApplicationCommandOptionType.Integer, ApplicationCommandOptionType.Number].includes(option.type);
35+
}
36+
37+
export function hasChoicesAndAutocompleteSupport(
38+
option: APIApplicationCommandOption
39+
): option is APIApplicationCommandChoosableAndAutocompletableTypes {
40+
return [ApplicationCommandOptionType.Integer, ApplicationCommandOptionType.Number, ApplicationCommandOptionType.String].includes(option.type);
41+
}
42+
43+
export function hasMinMaxLengthSupport(option: APIApplicationCommandOption): option is APIApplicationCommandStringOption {
44+
return option.type === ApplicationCommandOptionType.String;
45+
}
46+
47+
export interface CommandDifference {
48+
key: string;
49+
expected: string;
50+
original: string;
51+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { CommandDifference } from './_shared';
2+
3+
export function* checkDefaultMemberPermissions(oldPermissions?: string | null, newPermissions?: string | null): Generator<CommandDifference> {
4+
if (oldPermissions !== newPermissions) {
5+
yield {
6+
key: 'defaultMemberPermissions',
7+
original: String(oldPermissions),
8+
expected: String(newPermissions)
9+
};
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { CommandDifference } from './_shared';
2+
3+
export function* checkDescription({
4+
oldDescription,
5+
newDescription,
6+
key = 'description'
7+
}: {
8+
oldDescription: string;
9+
newDescription: string;
10+
key?: string;
11+
}): Generator<CommandDifference> {
12+
if (oldDescription !== newDescription) {
13+
yield {
14+
key,
15+
original: oldDescription,
16+
expected: newDescription
17+
};
18+
}
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { CommandDifference } from './_shared';
2+
3+
export function* checkDMPermission(oldDmPermission?: boolean, newDmPermission?: boolean): Generator<CommandDifference> {
4+
if ((oldDmPermission ?? true) !== (newDmPermission ?? true)) {
5+
yield {
6+
key: 'dmPermission',
7+
original: String(oldDmPermission ?? true),
8+
expected: String(newDmPermission ?? true)
9+
};
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { LocalizationMap } from 'discord-api-types/v10';
2+
import type { CommandDifference } from './_shared';
3+
4+
export function* checkLocalizations({
5+
localeMapName,
6+
localePresentMessage,
7+
localeMissingMessage,
8+
originalLocalizedDescriptions,
9+
expectedLocalizedDescriptions
10+
}: {
11+
localeMapName: string;
12+
localePresentMessage: string;
13+
localeMissingMessage: string;
14+
originalLocalizedDescriptions?: LocalizationMap | null;
15+
expectedLocalizedDescriptions?: LocalizationMap | null;
16+
}) {
17+
if (!originalLocalizedDescriptions && expectedLocalizedDescriptions) {
18+
yield {
19+
key: localeMapName,
20+
original: localeMissingMessage,
21+
expected: localePresentMessage
22+
};
23+
} else if (originalLocalizedDescriptions && !expectedLocalizedDescriptions) {
24+
yield {
25+
key: localeMapName,
26+
original: localePresentMessage,
27+
expected: localeMissingMessage
28+
};
29+
} else if (originalLocalizedDescriptions && expectedLocalizedDescriptions) {
30+
yield* reportLocalizationMapDifferences(originalLocalizedDescriptions, expectedLocalizedDescriptions, localeMapName);
31+
}
32+
}
33+
34+
function* reportLocalizationMapDifferences(
35+
originalMap: LocalizationMap,
36+
expectedMap: LocalizationMap,
37+
mapName: string
38+
): Generator<CommandDifference> {
39+
const originalLocalizations = new Map(Object.entries(originalMap));
40+
41+
for (const [key, value] of Object.entries(expectedMap)) {
42+
const possiblyExistingEntry = originalLocalizations.get(key) as string | undefined;
43+
originalLocalizations.delete(key);
44+
45+
const wasMissingBefore = typeof possiblyExistingEntry === 'undefined';
46+
const isResetNow = value === null;
47+
48+
// Was missing before and now is present
49+
if (wasMissingBefore && !isResetNow) {
50+
yield {
51+
key: `${mapName}.${key}`,
52+
original: 'no localization present',
53+
expected: value
54+
};
55+
}
56+
// Was present before and now is reset
57+
else if (!wasMissingBefore && isResetNow) {
58+
yield {
59+
key: `${mapName}.${key}`,
60+
original: possiblyExistingEntry,
61+
expected: 'no localization present'
62+
};
63+
}
64+
// Not equal
65+
// eslint-disable-next-line no-negated-condition
66+
else if (possiblyExistingEntry !== value) {
67+
yield {
68+
key: `${mapName}.${key}`,
69+
original: String(possiblyExistingEntry),
70+
expected: String(value)
71+
};
72+
}
73+
}
74+
75+
// Report any remaining localizations
76+
for (const [key, value] of originalLocalizations) {
77+
if (value) {
78+
yield {
79+
key: `${mapName}.${key}`,
80+
original: value,
81+
expected: 'no localization present'
82+
};
83+
}
84+
}
85+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { CommandDifference } from './_shared';
2+
3+
export function* checkName({ oldName, newName, key = 'name' }: { oldName: string; newName: string; key?: string }): Generator<CommandDifference> {
4+
if (oldName !== newName) {
5+
yield {
6+
key,
7+
original: oldName,
8+
expected: newName
9+
};
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import {
2+
ApplicationCommandOptionType,
3+
type APIApplicationCommandBasicOption,
4+
type APIApplicationCommandOption,
5+
type APIApplicationCommandStringOption
6+
} from 'discord-api-types/v10';
7+
import {
8+
hasChoicesAndAutocompleteSupport,
9+
hasMinMaxLengthSupport,
10+
hasMinMaxValueSupport,
11+
optionTypeToPrettyName,
12+
subcommandTypes,
13+
type APIApplicationCommandChoosableAndAutocompletableTypes,
14+
type APIApplicationCommandNumericTypes,
15+
type APIApplicationCommandSubcommandTypes,
16+
type CommandDifference
17+
} from './_shared';
18+
import { checkDescription } from './description';
19+
import { checkLocalizations } from './localizations';
20+
import { checkName } from './name';
21+
import { handleAutocomplete } from './option/autocomplete';
22+
import { handleMinMaxLengthOptions } from './option/minMaxLength';
23+
import { handleMinMaxValueOptions } from './option/minMaxValue';
24+
import { checkOptionRequired } from './option/required';
25+
import { checkOptionType } from './option/type';
26+
27+
export function* reportOptionDifferences({
28+
option,
29+
existingOption,
30+
currentIndex,
31+
keyPath = (index: number) => `options[${index}]`
32+
}: {
33+
option: APIApplicationCommandOption;
34+
currentIndex: number;
35+
existingOption?: APIApplicationCommandOption;
36+
keyPath?: (index: number) => string;
37+
}): Generator<CommandDifference> {
38+
// If current option doesn't exist, report and return
39+
if (!existingOption) {
40+
const expectedType = optionTypeToPrettyName.get(option.type) ?? `unknown (${option.type}); please contact Sapphire developers about this!`;
41+
42+
yield {
43+
key: keyPath(currentIndex),
44+
expected: `${expectedType} with name ${option.name}`,
45+
original: 'no option present'
46+
};
47+
48+
return;
49+
}
50+
51+
// Check type
52+
yield* checkOptionType({
53+
key: `${keyPath(currentIndex)}.type`,
54+
originalType: existingOption.type,
55+
expectedType: option.type
56+
});
57+
58+
// Check name
59+
yield* checkName({
60+
key: `${keyPath(currentIndex)}.name`,
61+
oldName: existingOption.name,
62+
newName: option.name
63+
});
64+
65+
// Check localized names
66+
const originalLocalizedNames = existingOption.name_localizations;
67+
const expectedLocalizedNames = option.name_localizations;
68+
69+
yield* checkLocalizations({
70+
localeMapName: `${keyPath(currentIndex)}.nameLocalizations`,
71+
localePresentMessage: 'localized names',
72+
localeMissingMessage: 'no localized names',
73+
originalLocalizedDescriptions: originalLocalizedNames,
74+
expectedLocalizedDescriptions: expectedLocalizedNames
75+
});
76+
77+
// Check description
78+
yield* checkDescription({
79+
key: `${keyPath(currentIndex)}.description`,
80+
oldDescription: existingOption.description,
81+
newDescription: option.description
82+
});
83+
84+
// Check localized descriptions
85+
const originalLocalizedDescriptions = existingOption.description_localizations;
86+
const expectedLocalizedDescriptions = option.description_localizations;
87+
88+
yield* checkLocalizations({
89+
localeMapName: `${keyPath(currentIndex)}.descriptionLocalizations`,
90+
localePresentMessage: 'localized descriptions',
91+
localeMissingMessage: 'no localized descriptions',
92+
originalLocalizedDescriptions,
93+
expectedLocalizedDescriptions
94+
});
95+
96+
// Check required
97+
yield* checkOptionRequired({
98+
key: `${keyPath(currentIndex)}.required`,
99+
oldRequired: existingOption.required,
100+
newRequired: option.required
101+
});
102+
103+
// Check for subcommands
104+
if (subcommandTypes.includes(existingOption.type) && subcommandTypes.includes(option.type)) {
105+
const castedExisting = existingOption as APIApplicationCommandSubcommandTypes;
106+
const castedExpected = option as APIApplicationCommandSubcommandTypes;
107+
108+
if (
109+
castedExisting.type === ApplicationCommandOptionType.SubcommandGroup &&
110+
castedExpected.type === ApplicationCommandOptionType.SubcommandGroup
111+
) {
112+
// We know we have options in this case, because they are both groups
113+
for (const [subcommandIndex, subcommandOption] of castedExpected.options!.entries()) {
114+
yield* reportOptionDifferences({
115+
currentIndex: subcommandIndex,
116+
option: subcommandOption,
117+
existingOption: castedExisting.options?.[subcommandIndex],
118+
keyPath: (index) => `${keyPath(currentIndex)}.options[${index}]`
119+
});
120+
}
121+
} else if (
122+
castedExisting.type === ApplicationCommandOptionType.Subcommand &&
123+
castedExpected.type === ApplicationCommandOptionType.Subcommand
124+
) {
125+
yield* handleSubcommandOptions({
126+
expectedOptions: castedExpected.options,
127+
existingOptions: castedExisting.options,
128+
currentIndex,
129+
keyPath
130+
});
131+
}
132+
}
133+
134+
if (hasMinMaxValueSupport(option)) {
135+
// Check min and max_value
136+
const existingCasted = existingOption as APIApplicationCommandNumericTypes;
137+
138+
yield* handleMinMaxValueOptions({
139+
currentIndex,
140+
existingOption: existingCasted,
141+
expectedOption: option,
142+
keyPath
143+
});
144+
}
145+
146+
if (hasChoicesAndAutocompleteSupport(option)) {
147+
const existingCasted = existingOption as APIApplicationCommandChoosableAndAutocompletableTypes;
148+
149+
yield* handleAutocomplete({
150+
expectedOption: option,
151+
existingOption: existingCasted,
152+
currentIndex,
153+
keyPath
154+
});
155+
}
156+
157+
if (hasMinMaxLengthSupport(option)) {
158+
// Check min and max_value
159+
const existingCasted = existingOption as APIApplicationCommandStringOption;
160+
161+
yield* handleMinMaxLengthOptions({
162+
currentIndex,
163+
existingOption: existingCasted,
164+
expectedOption: option,
165+
keyPath
166+
});
167+
}
168+
}
169+
170+
function* handleSubcommandOptions({
171+
expectedOptions,
172+
existingOptions,
173+
currentIndex,
174+
keyPath
175+
}: {
176+
expectedOptions?: APIApplicationCommandBasicOption[];
177+
existingOptions?: APIApplicationCommandBasicOption[];
178+
currentIndex: number;
179+
keyPath: (index: number) => string;
180+
}): Generator<CommandDifference> {
181+
// 0. No existing options and now we have options
182+
if (!existingOptions?.length && expectedOptions?.length) {
183+
yield {
184+
key: `${keyPath(currentIndex)}.options`,
185+
expected: 'options present',
186+
original: 'no options present'
187+
};
188+
}
189+
190+
// 1. Existing options and now we have no options
191+
else if (existingOptions?.length && !expectedOptions?.length) {
192+
yield {
193+
key: `${keyPath(currentIndex)}.options`,
194+
expected: 'no options present',
195+
original: 'options present'
196+
};
197+
}
198+
199+
// 2. Iterate over each option if we have any and see what's different
200+
else if (expectedOptions?.length) {
201+
let processedIndex = 0;
202+
for (const subcommandOption of expectedOptions) {
203+
const currentSubCommandOptionIndex = processedIndex++;
204+
const existingSubcommandOption = existingOptions![currentSubCommandOptionIndex];
205+
206+
yield* reportOptionDifferences({
207+
currentIndex: currentSubCommandOptionIndex,
208+
option: subcommandOption,
209+
existingOption: existingSubcommandOption,
210+
keyPath: (index) => `${keyPath(currentIndex)}.options[${index}]`
211+
});
212+
}
213+
214+
// If we went through less options than we previously had, report that
215+
if (processedIndex < existingOptions!.length) {
216+
let option: APIApplicationCommandOption;
217+
while ((option = existingOptions![processedIndex]) !== undefined) {
218+
const expectedType =
219+
optionTypeToPrettyName.get(option.type) ?? `unknown (${option.type}); please contact Sapphire developers about this!`;
220+
221+
yield {
222+
key: `existing command option at path ${keyPath(currentIndex)}.options[${processedIndex}]`,
223+
expected: 'no option present',
224+
original: `${expectedType} with name ${option.name}`
225+
};
226+
227+
processedIndex++;
228+
}
229+
}
230+
}
231+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { APIApplicationCommandOptionChoice } from 'discord-api-types/v10';
2+
import type { APIApplicationCommandChoosableAndAutocompletableTypes, CommandDifference } from '../_shared';
3+
import { checkLocalizations } from '../localizations';
4+
5+
export function* handleAutocomplete({
6+
currentIndex,
7+
existingOption,
8+
expectedOption,
9+
keyPath
10+
}: {
11+
currentIndex: number;
12+
keyPath: (index: number) => string;
13+
expectedOption: APIApplicationCommandChoosableAndAutocompletableTypes;
14+
existingOption: APIApplicationCommandChoosableAndAutocompletableTypes;
15+
}): Generator<CommandDifference> {
16+
// 0. No autocomplete and now it should autocomplete
17+
if (!existingOption.autocomplete && expectedOption.autocomplete) {
18+
yield {
19+
key: `${keyPath(currentIndex)}.autocomplete`,
20+
expected: 'autocomplete enabled',
21+
original: 'autocomplete disabled'
22+
};
23+
}
24+
// 1. Have autocomplete and now it shouldn't
25+
else if (existingOption.autocomplete && !expectedOption.autocomplete) {
26+
yield {
27+
key: `${keyPath(currentIndex)}.autocomplete`,
28+
expected: 'autocomplete disabled',
29+
original: 'autocomplete enabled'
30+
};
31+
}
32+
33+
if (!expectedOption.autocomplete && !existingOption.autocomplete) {
34+
// 0. No choices and now we have choices
35+
if (!existingOption.choices?.length && expectedOption.choices?.length) {
36+
yield {
37+
key: `${keyPath(currentIndex)}.choices`,
38+
expected: 'choices present',
39+
original: 'no choices present'
40+
};
41+
}
42+
// 1. Have choices and now we don't
43+
else if (existingOption.choices?.length && !expectedOption.choices?.length) {
44+
yield {
45+
key: `${keyPath(currentIndex)}.choices`,
46+
expected: 'no choices present',
47+
original: 'choices present'
48+
};
49+
}
50+
// 2. Check every choice to see differences
51+
else if (expectedOption.choices?.length && existingOption.choices?.length) {
52+
let index = 0;
53+
for (const choice of expectedOption.choices) {
54+
const currentChoiceIndex = index++;
55+
const existingChoice = existingOption.choices[currentChoiceIndex];
56+
57+
// If this choice never existed, return the difference
58+
if (existingChoice === undefined) {
59+
yield {
60+
key: `${keyPath(currentIndex)}.choices[${currentChoiceIndex}]`,
61+
original: 'no choice present',
62+
expected: 'choice present'
63+
};
64+
} else {
65+
if (choice.name !== existingChoice.name) {
66+
yield {
67+
key: `${keyPath(currentIndex)}.choices[${currentChoiceIndex}].name`,
68+
original: existingChoice.name,
69+
expected: choice.name
70+
};
71+
}
72+
73+
// Check localized names
74+
const originalLocalizedNames = existingChoice.name_localizations;
75+
const expectedLocalizedNames = choice.name_localizations;
76+
77+
yield* checkLocalizations({
78+
localeMapName: `${keyPath(currentIndex)}.choices[${currentChoiceIndex}].nameLocalizations`,
79+
localePresentMessage: 'localized names',
80+
localeMissingMessage: 'no localized names',
81+
originalLocalizedDescriptions: originalLocalizedNames,
82+
expectedLocalizedDescriptions: expectedLocalizedNames
83+
});
84+
85+
if (choice.value !== existingChoice.value) {
86+
yield {
87+
key: `${keyPath(currentIndex)}.choices[${currentChoiceIndex}].value`,
88+
original: String(existingChoice.value),
89+
expected: String(choice.value)
90+
};
91+
}
92+
}
93+
}
94+
95+
// If there are more choices than the expected ones, return the difference
96+
if (index < existingOption.choices.length) {
97+
let choice: APIApplicationCommandOptionChoice;
98+
while ((choice = existingOption.choices[index]) !== undefined) {
99+
yield {
100+
key: `existing choice at path ${keyPath(currentIndex)}.choices[${index}]`,
101+
expected: 'no choice present',
102+
original: `choice with name "${choice.name}" and value ${
103+
typeof choice.value === 'number' ? choice.value : `"${choice.value}"`
104+
} present`
105+
};
106+
107+
index++;
108+
}
109+
}
110+
}
111+
}
112+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { APIApplicationCommandStringOption } from 'discord-api-types/v10';
2+
import type { CommandDifference } from '../_shared';
3+
4+
export function* handleMinMaxLengthOptions({
5+
currentIndex,
6+
existingOption,
7+
expectedOption,
8+
keyPath
9+
}: {
10+
currentIndex: number;
11+
keyPath: (index: number) => string;
12+
expectedOption: APIApplicationCommandStringOption;
13+
existingOption: APIApplicationCommandStringOption;
14+
}): Generator<CommandDifference> {
15+
// 0. No min_length and now we have min_length
16+
if (existingOption.min_length === undefined && expectedOption.min_length !== undefined) {
17+
yield {
18+
key: `${keyPath(currentIndex)}.min_length`,
19+
expected: 'min_length present',
20+
original: 'no min_length present'
21+
};
22+
}
23+
// 1. Have min_length and now we don't
24+
else if (existingOption.min_length !== undefined && expectedOption.min_length === undefined) {
25+
yield {
26+
key: `${keyPath(currentIndex)}.min_length`,
27+
expected: 'no min_length present',
28+
original: 'min_length present'
29+
};
30+
}
31+
// 2. Equality check
32+
else if (existingOption.min_length !== expectedOption.min_length) {
33+
yield {
34+
key: `${keyPath(currentIndex)}.min_length`,
35+
original: String(existingOption.min_length),
36+
expected: String(expectedOption.min_length)
37+
};
38+
}
39+
40+
// 0. No max_length and now we have max_length
41+
if (existingOption.max_length === undefined && expectedOption.max_length !== undefined) {
42+
yield {
43+
key: `${keyPath(currentIndex)}.max_length`,
44+
expected: 'max_length present',
45+
original: 'no max_length present'
46+
};
47+
}
48+
// 1. Have max_length and now we don't
49+
else if (existingOption.max_length !== undefined && expectedOption.max_length === undefined) {
50+
yield {
51+
key: `${keyPath(currentIndex)}.max_length`,
52+
expected: 'no max_length present',
53+
original: 'max_length present'
54+
};
55+
}
56+
// 2. Equality check
57+
else if (existingOption.max_length !== expectedOption.max_length) {
58+
yield {
59+
key: `${keyPath(currentIndex)}.max_length`,
60+
original: String(existingOption.max_length),
61+
expected: String(expectedOption.max_length)
62+
};
63+
}
64+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { APIApplicationCommandNumericTypes, CommandDifference } from '../_shared';
2+
3+
export function* handleMinMaxValueOptions({
4+
currentIndex,
5+
existingOption,
6+
expectedOption,
7+
keyPath
8+
}: {
9+
currentIndex: number;
10+
keyPath: (index: number) => string;
11+
expectedOption: APIApplicationCommandNumericTypes;
12+
existingOption: APIApplicationCommandNumericTypes;
13+
}): Generator<CommandDifference> {
14+
// 0. No min_value and now we have min_value
15+
if (existingOption.min_value === undefined && expectedOption.min_value !== undefined) {
16+
yield {
17+
key: `${keyPath(currentIndex)}.min_value`,
18+
expected: 'min_value present',
19+
original: 'no min_value present'
20+
};
21+
}
22+
// 1. Have min_value and now we don't
23+
else if (existingOption.min_value !== undefined && expectedOption.min_value === undefined) {
24+
yield {
25+
key: `${keyPath(currentIndex)}.min_value`,
26+
expected: 'no min_value present',
27+
original: 'min_value present'
28+
};
29+
}
30+
// 2. Equality check
31+
else if (existingOption.min_value !== expectedOption.min_value) {
32+
yield {
33+
key: `${keyPath(currentIndex)}.min_value`,
34+
original: String(existingOption.min_value),
35+
expected: String(expectedOption.min_value)
36+
};
37+
}
38+
39+
// 0. No max_value and now we have max_value
40+
if (existingOption.max_value === undefined && expectedOption.max_value !== undefined) {
41+
yield {
42+
key: `${keyPath(currentIndex)}.max_value`,
43+
expected: 'max_value present',
44+
original: 'no max_value present'
45+
};
46+
}
47+
// 1. Have max_value and now we don't
48+
else if (existingOption.max_value !== undefined && expectedOption.max_value === undefined) {
49+
yield {
50+
key: `${keyPath(currentIndex)}.max_value`,
51+
expected: 'no max_value present',
52+
original: 'max_value present'
53+
};
54+
}
55+
// 2. Equality check
56+
else if (existingOption.max_value !== expectedOption.max_value) {
57+
yield {
58+
key: `${keyPath(currentIndex)}.max_value`,
59+
original: String(existingOption.max_value),
60+
expected: String(expectedOption.max_value)
61+
};
62+
}
63+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { CommandDifference } from '../_shared';
2+
3+
export function* checkOptionRequired({
4+
oldRequired,
5+
newRequired,
6+
key
7+
}: {
8+
oldRequired?: boolean;
9+
newRequired?: boolean;
10+
key: string;
11+
}): Generator<CommandDifference> {
12+
if ((oldRequired ?? false) !== (newRequired ?? false)) {
13+
yield {
14+
key,
15+
original: String(oldRequired ?? false),
16+
expected: String(newRequired ?? false)
17+
};
18+
}
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { ApplicationCommandOptionType } from 'discord-api-types/v10';
2+
import { optionTypeToPrettyName, type CommandDifference } from '../_shared';
3+
4+
export function* checkOptionType({
5+
key,
6+
expectedType,
7+
originalType
8+
}: {
9+
key: string;
10+
originalType: ApplicationCommandOptionType;
11+
expectedType: ApplicationCommandOptionType;
12+
}): Generator<CommandDifference> {
13+
const expectedTypeString =
14+
optionTypeToPrettyName.get(expectedType) ?? `unknown (${expectedType}); please contact Sapphire developers about this!`;
15+
16+
if (originalType !== expectedType) {
17+
yield {
18+
key,
19+
original: optionTypeToPrettyName.get(originalType) ?? `unknown (${originalType}); please contact Sapphire developers about this!`,
20+
expected: expectedTypeString
21+
};
22+
}
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { APIApplicationCommandOption } from 'discord-api-types/v10';
2+
import { optionTypeToPrettyName, type CommandDifference } from './_shared';
3+
import { reportOptionDifferences } from './option';
4+
5+
export function* checkOptions(
6+
existingOptions?: APIApplicationCommandOption[],
7+
newOptions?: APIApplicationCommandOption[]
8+
): Generator<CommandDifference> {
9+
// 0. No existing options and now we have options
10+
if (!existingOptions?.length && newOptions?.length) {
11+
yield {
12+
key: 'options',
13+
original: 'no options present',
14+
expected: 'options present'
15+
};
16+
}
17+
// 1. Existing options and now we have no options
18+
else if (existingOptions?.length && !newOptions?.length) {
19+
yield {
20+
key: 'options',
21+
original: 'options present',
22+
expected: 'no options present'
23+
};
24+
}
25+
// 2. Iterate over each option if we have any and see what's different
26+
else if (newOptions?.length) {
27+
let index = 0;
28+
for (const option of newOptions) {
29+
const currentIndex = index++;
30+
const existingOption = existingOptions![currentIndex];
31+
yield* reportOptionDifferences({ currentIndex, option, existingOption });
32+
}
33+
34+
// If we went through less options than we previously had, report that
35+
if (index < existingOptions!.length) {
36+
let option: APIApplicationCommandOption;
37+
while ((option = existingOptions![index]) !== undefined) {
38+
const expectedType =
39+
optionTypeToPrettyName.get(option.type) ?? `unknown (${option.type}); please contact Sapphire developers about this!`;
40+
41+
yield {
42+
key: `existing command option at index ${index}`,
43+
expected: 'no option present',
44+
original: `${expectedType} with name ${option.name}`
45+
};
46+
47+
index++;
48+
}
49+
}
50+
}
51+
}

‎src/lib/utils/application-commands/computeDifferences.ts

+38-636
Large diffs are not rendered by default.

‎src/lib/utils/application-commands/normalizeInputs.ts

+14-19
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from 'discord-api-types/v10';
1515
import {
1616
ApplicationCommand,
17+
PermissionsBitField,
1718
type ChatInputApplicationCommandData,
1819
type MessageApplicationCommandData,
1920
type UserApplicationCommandData
@@ -33,20 +34,12 @@ function addDefaultsToChatInputJSON(data: RESTPostAPIChatInputApplicationCommand
3334
data.dm_permission ??= true;
3435
data.type ??= ApplicationCommandType.ChatInput;
3536

36-
// Localizations default to null from d.js
37-
data.name_localizations ??= null;
38-
data.description_localizations ??= null;
39-
4037
return data;
4138
}
4239

4340
function addDefaultsToContextMenuJSON(data: RESTPostAPIContextMenuApplicationCommandsJSONBody): RESTPostAPIContextMenuApplicationCommandsJSONBody {
4441
data.dm_permission ??= true;
4542

46-
// Localizations default to null from d.js
47-
data.name_localizations ??= null;
48-
data.description_localizations ??= null;
49-
5043
return data;
5144
}
5245

@@ -75,11 +68,13 @@ export function normalizeChatInputCommand(
7568
description: command.description,
7669
description_localizations: command.descriptionLocalizations,
7770
type: ApplicationCommandType.ChatInput,
78-
dm_permission: command.dmPermission
71+
dm_permission: command.dmPermission,
72+
nsfw: command.nsfw
7973
};
8074

81-
if (command.defaultMemberPermissions) {
82-
finalObject.default_member_permissions = String(command.defaultMemberPermissions);
75+
if (typeof command.defaultMemberPermissions !== 'undefined') {
76+
finalObject.default_member_permissions =
77+
command.defaultMemberPermissions === null ? null : new PermissionsBitField(command.defaultMemberPermissions).bitfield.toString();
8378
}
8479

8580
if (command.options?.length) {
@@ -110,11 +105,13 @@ export function normalizeContextMenuCommand(
110105
name: command.name,
111106
name_localizations: command.nameLocalizations,
112107
type: command.type,
113-
dm_permission: command.dmPermission
108+
dm_permission: command.dmPermission,
109+
nsfw: command.nsfw
114110
};
115111

116-
if (command.defaultMemberPermissions) {
117-
finalObject.default_member_permissions = String(command.defaultMemberPermissions);
112+
if (typeof command.defaultMemberPermissions !== 'undefined') {
113+
finalObject.default_member_permissions =
114+
command.defaultMemberPermissions === null ? null : new PermissionsBitField(command.defaultMemberPermissions).bitfield.toString();
118115
}
119116

120117
return addDefaultsToContextMenuJSON(finalObject);
@@ -124,13 +121,11 @@ export function convertApplicationCommandToApiData(command: ApplicationCommand):
124121
const returnData = {
125122
name: command.name,
126123
name_localizations: command.nameLocalizations,
127-
dm_permission: command.dmPermission
124+
dm_permission: command.dmPermission,
125+
nsfw: command.nsfw,
126+
default_member_permissions: command.defaultMemberPermissions?.bitfield.toString() ?? null
128127
} as RESTPostAPIApplicationCommandsJSONBody;
129128

130-
if (command.defaultMemberPermissions) {
131-
returnData.default_member_permissions = command.defaultMemberPermissions.bitfield.toString();
132-
}
133-
134129
if (command.type === ApplicationCommandType.ChatInput) {
135130
returnData.type = ApplicationCommandType.ChatInput;
136131
(returnData as RESTPostAPIChatInputApplicationCommandsJSONBody).description = command.description;

0 commit comments

Comments
 (0)
Please sign in to comment.