refactor(SendSlash): Perfect 🐛

This commit is contained in:
March 7th 2022-08-15 19:51:01 +07:00
parent 89349b9eac
commit 347caf9dce
5 changed files with 308 additions and 237 deletions

View File

@ -2,6 +2,10 @@
- Support Autocomplete feature (half) - Support Autocomplete feature (half)
- Unused `guild.searchInteraction()` (Use only if not working properly) - Unused `guild.searchInteraction()` (Use only if not working properly)
# <strong>BREAKING CHANGE: Using Slash Command (Sub Command / Sub Group Command) will not accept subCommand argument in args. That means Command Name needs to be changed same as Discord Client</strong>
# All image demo : v2.3
# Slash Command (no options) # Slash Command (no options)
### Demo ### Demo
@ -14,7 +18,6 @@
```js ```js
await message.channel.sendSlash('botid', 'aiko') await message.channel.sendSlash('botid', 'aiko')
// Return nonce (view document)
``` ```
### Result ### Result
@ -23,15 +26,17 @@ await message.channel.sendSlash('botid', 'aiko')
# Slash Command + Sub option (group) # Slash Command + Sub option (group)
### Demo ### Demo (v2.5)
![image](https://user-images.githubusercontent.com/71698422/173346438-678009a1-870c-49a2-97fe-8ceed4f1ab64.png) ![image](https://user-images.githubusercontent.com/71698422/173346438-678009a1-870c-49a2-97fe-8ceed4f1ab64.png)
### Code test ### Code test
```js ```diff
await message.channel.sendSlash('450323683840491530', 'animal', 'chat', 'bye') v2.5
// Return nonce (view document) - await message.channel.sendSlash('450323683840491530', 'animal', 'chat', 'bye')
v2.6+
+ await message.channel.sendSlash('450323683840491530', 'animal chat', 'bye')
``` ```
### Result ### Result
@ -51,7 +56,6 @@ const { MessageAttachment } = require('discord.js-selfbot-v13')
const fs = require('fs') const fs = require('fs')
const a = new MessageAttachment(fs.readFileSync('./wallpaper.jpg') , 'test.jpg') const a = new MessageAttachment(fs.readFileSync('./wallpaper.jpg') , 'test.jpg')
await message.channel.sendSlash('718642000898818048', 'sauce', a) await message.channel.sendSlash('718642000898818048', 'sauce', a)
// Return nonce (view document)
``` ```
### Result ### Result

View File

@ -50,6 +50,7 @@ const Messages = {
/* Add */ /* Add */
MISSING_PERMISSIONS: (...permission) => `You can't do this action [Missing Permission(s): ${permission.join(', ')}]`, MISSING_PERMISSIONS: (...permission) => `You can't do this action [Missing Permission(s): ${permission.join(', ')}]`,
EMBED_PROVIDER_NAME: 'MessageEmbed provider name must be a string.', EMBED_PROVIDER_NAME: 'MessageEmbed provider name must be a string.',
INVALID_COMMAND_NAME: allCMD => `Could not parse subGroupCommand and subCommand due to too long: ${allCMD.join(' ')}`,
BUTTON_LABEL: 'MessageButton label must be a string', BUTTON_LABEL: 'MessageButton label must be a string',
BUTTON_URL: 'MessageButton URL must be a string', BUTTON_URL: 'MessageButton URL must be a string',

View File

@ -10,6 +10,7 @@ const Permissions = require('../util/Permissions');
const SnowflakeUtil = require('../util/SnowflakeUtil'); const SnowflakeUtil = require('../util/SnowflakeUtil');
const { lazy } = require('../util/Util'); const { lazy } = require('../util/Util');
const Message = lazy(() => require('../structures/Message').Message); const Message = lazy(() => require('../structures/Message').Message);
/** /**
* Represents an application command. * Represents an application command.
* @extends {Base} * @extends {Base}
@ -588,11 +589,15 @@ class ApplicationCommand extends Base {
/** /**
* Send Slash command to channel * Send Slash command to channel
* @param {Message} message Discord Message * @param {Message} message Discord Message
* @param {Array<string>} subCommandArray SubCommand Array
* @param {Array<string>} options The options to Slash Command * @param {Array<string>} options The options to Slash Command
* @returns {Promise<InteractionResponseBody>} * @returns {Promise<InteractionResponseBody>}
*/ */
async sendSlashCommand(message, options = []) { // eslint-disable-next-line consistent-return
async sendSlashCommand(message, subCommandArray = [], options = []) {
// Todo: Refactor // Todo: Refactor
const buildError = (type, value, array, msg) =>
new Error(`Invalid ${type}: ${value} ${msg}\nList of ${type}:\n${array}`);
// Check Options // Check Options
if (!(message instanceof Message())) { if (!(message instanceof Message())) {
throw new TypeError('The message must be a Discord.Message'); throw new TypeError('The message must be a Discord.Message');
@ -604,6 +609,167 @@ class ApplicationCommand extends Base {
const optionFormat = []; const optionFormat = [];
const attachments = []; const attachments = [];
const attachmentsBuffer = []; const attachmentsBuffer = [];
const parseChoices = (list_choices, value) => {
if (value !== undefined) {
if (Array.isArray(list_choices) && list_choices.length) {
const choice = list_choices.find(c => c.name === value) || list_choices.find(c => c.value === value);
if (choice) {
return choice.value;
}
throw buildError(
'choice',
value,
list_choices.map((c, i) => ` #${i + 1} Name: ${c.name} Value: ${c.value}`).join('\n'),
'is not a valid choice for this option',
);
} else {
return value;
}
} else {
return undefined;
}
};
const parseOption = async (optionCommand, value) => {
const data = {
type: ApplicationCommandOptionTypes[optionCommand.type],
name: optionCommand.name,
};
if (value !== undefined) {
value = parseChoices(optionCommand.choices, value);
switch (optionCommand.type) {
case 'BOOLEAN': {
data.value = Boolean(value);
break;
}
case 'INTEGER': {
data.value = Number(value);
break;
}
case 'ATTACHMENT': {
data.value = await addDataFromAttachment(value);
break;
}
case 'SUB_COMMAND_GROUP': {
break;
}
default: {
if (optionCommand.autocomplete) {
let optionsBuild;
switch (subCommandArray.length) {
case 0: {
optionsBuild = [
...optionFormat,
{
type: ApplicationCommandOptionTypes[optionCommand.type],
name: optionCommand.name,
value,
focused: true,
},
];
break;
}
case 1: {
const subCommand = this.options.find(o => o.name == subCommandArray[0] && o.type == 'SUB_COMMAND');
optionsBuild = [
{
type: ApplicationCommandOptionTypes[subCommand.type],
name: subCommand.name,
options: [
...optionFormat,
{
type: ApplicationCommandOptionTypes[optionCommand.type],
name: optionCommand.name,
value,
focused: true,
},
],
},
];
break;
}
case 2: {
const subGroup = this.options.find(
o => o.name == subCommandArray[0] && o.type == 'SUB_COMMAND_GROUP',
);
const subCommand = this.options.find(o => o.name == subCommandArray[1] && o.type == 'SUB_COMMAND');
optionsBuild = [
{
type: ApplicationCommandOptionTypes[subGroup.type],
name: subGroup.name,
options: [
{
type: ApplicationCommandOptionTypes[subCommand.type],
name: subCommand.name,
options: [
...optionFormat,
{
type: ApplicationCommandOptionTypes[optionCommand.type],
name: optionCommand.name,
value,
focused: true,
},
],
},
],
},
];
break;
}
}
const autoValue = await getAutoResponse(optionsBuild, value);
data.value = autoValue;
} else {
data.value = value;
}
}
}
optionFormat.push(data);
}
return optionFormat;
};
const parseSubCommand = async (subCommandName, options) => {
const subCommand = this.options.find(o => o.name == subCommandName && o.type == 'SUB_COMMAND');
if (!subCommand) {
throw buildError(
'SubCommand',
subCommandName,
this.options.map((o, i) => ` #${i + 1} Name: ${o.name}`).join('\n'),
'is not a valid sub command',
);
}
const valueRequired = subCommand.options.filter(o => o.required).length;
for (let i = 0; i < options.length; i++) {
const optionInput = subCommand.options[i];
const value = options[i];
await parseOption(optionInput, value);
}
if (valueRequired > options.length) {
throw new Error(`Value required missing\nDebug:
Required: ${valueRequired} - Options: ${optionFormat.length}`);
}
return {
type: ApplicationCommandOptionTypes[subCommand.type],
name: subCommand.name,
options: optionFormat,
};
};
const parseSubGroupCommand = async (subGroupName, subName) => {
const subGroup = this.options.find(o => o.name == subGroupName && o.type == 'SUB_COMMAND_GROUP');
if (!subGroup) {
throw buildError(
'SubGroupCommand',
subGroupName,
this.options.map((o, i) => ` #${i + 1} Name: ${o.name}`).join('\n'),
'is not a valid sub group command',
);
}
const data = await parseSubCommand(subName, options);
return {
type: ApplicationCommandOptionTypes[subGroup.type],
name: subGroup.name,
options: [data],
};
};
async function addDataFromAttachment(data) { async function addDataFromAttachment(data) {
if (!(data instanceof MessageAttachment)) { if (!(data instanceof MessageAttachment)) {
throw new TypeError('The attachment data must be a Discord.MessageAttachment'); throw new TypeError('The attachment data must be a Discord.MessageAttachment');
@ -617,34 +783,14 @@ class ApplicationCommand extends Base {
attachmentsBuffer.push({ attachment: data.attachment, name: data.name, file: resource }); attachmentsBuffer.push({ attachment: data.attachment, name: data.name, file: resource });
return id; return id;
} }
let option_ = []; const getDataPost = (guildAdd = false, dataAdd = [], nonce, autocomplete = false) => {
let i = 0; if (!Array.isArray(dataAdd) && typeof dataAdd == 'object') {
// Check Command type is Sub group ? dataAdd = [dataAdd];
const subCommandCheck = this.options.some(option => ['SUB_COMMAND', 'SUB_COMMAND_GROUP'].includes(option.type));
let subCommand;
if (subCommandCheck) {
subCommand = this.options.find(option => option.name == options[0]);
options.shift();
option_[0] = {
type: ApplicationCommandOptionTypes[subCommand.type],
name: subCommand.name,
options: optionFormat,
};
} else {
option_ = optionFormat;
} }
// Autoresponse if (guildAdd) {
const getAutoResponse = async (options_, type, name, value) => { return {
const op = Array.from(options_); type: autocomplete ? 4 : 2, // Slash command, context menu
op.push({ // Type: 4: Auto-complete
type,
name,
value,
focused: true,
});
let nonce = SnowflakeUtil.generate();
const data = {
type: 4, // Auto-complete
application_id: this.applicationId, application_id: this.applicationId,
guild_id: message.guildId, guild_id: message.guildId,
channel_id: message.channelId, channel_id: message.channelId,
@ -655,11 +801,36 @@ class ApplicationCommand extends Base {
id: this.id, id: this.id,
name: this.name, name: this.name,
type: ApplicationCommandTypes[this.type], type: ApplicationCommandTypes[this.type],
options: op, options: dataAdd,
attachments: attachments,
guild_id: message.guildId,
},
nonce,
};
} else {
return {
type: autocomplete ? 4 : 2, // Slash command, context menu
// Type: 4: Auto-complete
application_id: this.applicationId,
guild_id: message.guildId,
channel_id: message.channelId,
session_id: this.client.session_id,
data: {
// ApplicationCommandData
version: this.version,
id: this.id,
name: this.name,
type: ApplicationCommandTypes[this.type],
options: dataAdd,
attachments: attachments, attachments: attachments,
}, },
nonce, nonce,
}; };
}
};
const getAutoResponse = async (sendData, value) => {
let nonce = SnowflakeUtil.generate();
const data = getDataPost(false, sendData, nonce, true);
await this.client.api.interactions await this.client.api.interactions
.post({ .post({
data, data,
@ -667,10 +838,9 @@ class ApplicationCommand extends Base {
}) })
.catch(async () => { .catch(async () => {
nonce = SnowflakeUtil.generate(); nonce = SnowflakeUtil.generate();
data.data.guild_id = message.guildId; const data_ = getDataPost(true, sendData, nonce);
data.nonce = nonce;
await this.client.api.interactions.post({ await this.client.api.interactions.post({
data, body: data_,
files: attachmentsBuffer, files: attachmentsBuffer,
}); });
}); });
@ -693,112 +863,9 @@ class ApplicationCommand extends Base {
this.client.on(Events.APPLICATION_COMMAND_AUTOCOMPLETE_RESPONSE, handler); this.client.on(Events.APPLICATION_COMMAND_AUTOCOMPLETE_RESPONSE, handler);
}); });
}; };
for (i; i < options.length; i++) { const sendData = async (optionsData = []) => {
const value = options[i];
if (!subCommandCheck && !this?.options[i]) continue;
if (subCommandCheck && subCommand?.options && !subCommand?.options[i]) continue;
if (!subCommandCheck) {
// Check value is invalid
let choice;
if (this.options[i].choices && this.options[i].choices.length > 0) {
choice =
this.options[i].choices.find(c => c.name == value) || this.options[i].choices.find(c => c.value == value);
if (!choice && value !== undefined) {
throw new Error(
`Invalid option: ${value} is not a valid choice for this option\nList of choices:\n${this.options[
i
].choices
.map((c, i) => ` #${i + 1} Name: ${c.name} Value: ${c.value}`)
.join('\n')}`,
);
}
}
const data = {
type: ApplicationCommandOptionTypes[this.options[i].type],
name: this.options[i].name,
value:
choice?.value || this.options[i].type == 'ATTACHMENT'
? await addDataFromAttachment(value)
: this.options[i].type == 'INTEGER'
? Number(value)
: this.options[i].type == 'BOOLEAN'
? Boolean(value)
: this.options[i].autocomplete
? await getAutoResponse(
optionFormat,
ApplicationCommandOptionTypes[this.options[i].type],
this.options[i].name,
value,
)
: value,
};
if (value !== undefined) optionFormat.push(data);
} else {
// First element is sub command and removed
if (!value) continue;
// Check value is invalid
let choice;
if (subCommand?.options && subCommand.options[i].choices && subCommand.options[i].choices.length > 0) {
choice =
subCommand.options[i].choices.find(c => c.name == value) ||
subCommand.options[i].choices.find(c => c.value == value);
if (!choice && value !== undefined) {
throw new Error(
`Invalid option: ${value} is not a valid choice for this option\nList of choices: \n${subCommand.options[
i
].choices
.map((c, i) => `#${i + 1} Name: ${c.name} Value: ${c.value}\n`)
.join('')}`,
);
}
}
const data = {
type: ApplicationCommandOptionTypes[subCommand.options[i].type],
name: subCommand.options[i].name,
value:
choice?.value || subCommand.options[i].type == 'ATTACHMENT'
? await addDataFromAttachment(value)
: subCommand.options[i].type == 'INTEGER'
? Number(value)
: subCommand.options[i].type == 'BOOLEAN'
? Boolean(value)
: this.options[i].autocomplete
? await getAutoResponse(
optionFormat,
ApplicationCommandOptionTypes[subCommand.options[i].type],
subCommand.options[i].name,
value,
)
: value,
};
if (value !== undefined) optionFormat.push(data);
}
}
if (!subCommandCheck && this.options[i]?.required) {
throw new Error('Value required missing');
}
if (subCommandCheck && subCommand?.options && subCommand?.options[i]?.required) {
throw new Error('Value required missing');
}
let nonce = SnowflakeUtil.generate(); let nonce = SnowflakeUtil.generate();
const data = { const data = getDataPost(false, optionsData, nonce);
type: 2, // Slash command, context menu
// Type: 4: Auto-complete
application_id: this.applicationId,
guild_id: message.guildId,
channel_id: message.channelId,
session_id: this.client.session_id,
data: {
// ApplicationCommandData
version: this.version,
id: this.id,
name: this.name,
type: ApplicationCommandTypes[this.type],
options: option_,
attachments: attachments,
},
nonce,
};
await this.client.api.interactions await this.client.api.interactions
.post({ .post({
body: data, body: data,
@ -806,10 +873,9 @@ class ApplicationCommand extends Base {
}) })
.catch(async () => { .catch(async () => {
nonce = SnowflakeUtil.generate(); nonce = SnowflakeUtil.generate();
data.data.guild_id = message.guildId; const data_ = getDataPost(true, optionsData, nonce);
data.nonce = nonce;
await this.client.api.interactions.post({ await this.client.api.interactions.post({
body: data, body: data_,
files: attachmentsBuffer, files: attachmentsBuffer,
}); });
}); });
@ -831,55 +897,34 @@ class ApplicationCommand extends Base {
this.client.incrementMaxListeners(); this.client.incrementMaxListeners();
this.client.on('interactionResponse', handler); this.client.on('interactionResponse', handler);
}); });
}
/**
* Message Context Menu
* @param {Message} message Discord Message
* @param {boolean} sendFromMessage nothing .-. not used
* @returns {Promise<InteractionResponseBody>}
*/
async sendContextMenu(message, sendFromMessage = false) {
if (!sendFromMessage && !(message instanceof Message())) {
throw new TypeError('The message must be a Discord.Message');
}
if (this.type == 'CHAT_INPUT') return false;
const nonce = SnowflakeUtil.generate();
await this.client.api.interactions.post({
body: {
type: 2, // Slash command, context menu
application_id: this.applicationId,
guild_id: message.guildId,
channel_id: message.channelId,
session_id: this.client.session_id,
data: {
// ApplicationCommandData
version: this.version,
id: this.id,
name: this.name,
type: ApplicationCommandTypes[this.type],
target_id: ApplicationCommandTypes[this.type] == 1 ? message.author.id : message.id,
},
nonce,
},
});
return new Promise((resolve, reject) => {
const handler = data => {
timeout.refresh();
if (data.metadata.nonce !== nonce) return;
clearTimeout(timeout);
this.client.removeListener('interactionResponse', handler);
this.client.decrementMaxListeners();
if (data.status) resolve(data.metadata);
else reject(data.metadata);
}; };
const timeout = setTimeout(() => { // SubCommandArray length max 2
this.client.removeListener('interactionResponse', handler); // length = 0 => no sub command
this.client.decrementMaxListeners(); // length = 1 => sub command
reject(new Error('INTERACTION_TIMEOUT')); // length = 2 => sub command group + sub command
}, 15_000).unref(); switch (subCommandArray.length) {
this.client.incrementMaxListeners(); case 0: {
this.client.on('interactionResponse', handler); const valueRequired = this.options.filter(o => o.required).length;
}); for (let i = 0; i < options.length; i++) {
const optionInput = this.options[i];
const value = options[i];
await parseOption(optionInput, value);
}
if (valueRequired > options.length) {
throw new Error(`Value required missing\nDebug:
Required: ${valueRequired} - Options: ${optionFormat.length}`);
}
return sendData(optionFormat);
}
case 1: {
const optionsData = await parseSubCommand(subCommandArray[0], options);
return sendData(optionsData);
}
case 2: {
const optionsData = await parseSubGroupCommand(subCommandArray[0], subCommandArray[1], options);
return sendData(optionsData);
}
}
} }
} }

View File

@ -10,6 +10,14 @@ const { TypeError, Error } = require('../../errors');
const InteractionCollector = require('../InteractionCollector'); const InteractionCollector = require('../InteractionCollector');
const { lazy } = require('../../util/Util'); const { lazy } = require('../../util/Util');
const Message = lazy(() => require('../Message').Message); const Message = lazy(() => require('../Message').Message);
const { s } = require('@sapphire/shapeshift');
const validateName = stringName =>
s.string
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(32)
.regex(/^[\p{Ll}\p{Lm}\p{Lo}\p{N}\p{sc=Devanagari}\p{sc=Thai}_-]+$/u)
.setValidationEnabled(true)
.parse(stringName);
/** /**
* Interface for classes that have text-channel-like features. * Interface for classes that have text-channel-like features.
@ -395,7 +403,7 @@ class TextBasedChannel {
/** /**
* Send Slash to this channel * Send Slash to this channel
* @param {Snowflake} botId Bot Id (Supports application ID - not bot) * @param {Snowflake} botId Bot Id (Supports application ID - not bot)
* @param {string} commandName Command name * @param {string} commandString Command name (and sub / group formats)
* @param {...?string|string[]} args Command arguments * @param {...?string|string[]} args Command arguments
* @returns {Promise<InteractionResponseBody>} * @returns {Promise<InteractionResponseBody>}
* @example * @example
@ -407,9 +415,21 @@ class TextBasedChannel {
* channel.sendSlash('123456789012345678', 'embed', 'title', 'description', 'author', '#00ff00') * channel.sendSlash('123456789012345678', 'embed', 'title', 'description', 'author', '#00ff00')
* // Send embed with Title and Color: * // Send embed with Title and Color:
* channel.sendSlash('123456789012345678', 'embed', 'title', undefined, undefined, '#00ff00') * channel.sendSlash('123456789012345678', 'embed', 'title', undefined, undefined, '#00ff00')
* // CommandName is Group Command / Sub Command
* channel.sendSlash('123456789012345678', 'embed title', 'description', 'author', '#00ff00')
*/ */
async sendSlash(botId, commandName, ...args) { async sendSlash(botId, commandString, ...args) {
args = args.flat(2); args = args.flat(2);
const cmd = commandString.trim().split(' ');
// Validate CommandName
const commandName = validateName(cmd[0]);
const sub = cmd.slice(1);
for (let i = 0; i < sub.length; i++) {
if (sub.length > 2) {
throw new Error('INVALID_COMMAND_NAME', cmd);
}
validateName(sub[i]);
}
if (!botId) throw new Error('Bot ID is required'); if (!botId) throw new Error('Bot ID is required');
// ? maybe ... // ? maybe ...
const user = await this.client.users.fetch(botId).catch(() => {}); const user = await this.client.users.fetch(botId).catch(() => {});
@ -463,6 +483,7 @@ class TextBasedChannel {
content: '', content: '',
id: this.client.user.id, id: this.client.user.id,
}), }),
sub && sub.length > 0 ? sub : [],
args && args.length ? args : [], args && args.length ? args : [],
); );
} }

2
typings/index.d.ts vendored
View File

@ -474,7 +474,7 @@ export class ApplicationCommand<PermissionsFetchType = {}> extends Base {
private static transformCommand(command: ApplicationCommandData): RESTPostAPIApplicationCommandsJSONBody; private static transformCommand(command: ApplicationCommandData): RESTPostAPIApplicationCommandsJSONBody;
private static isAPICommandData(command: object): command is RESTPostAPIApplicationCommandsJSONBody; private static isAPICommandData(command: object): command is RESTPostAPIApplicationCommandsJSONBody;
// Add // Add
public static sendSlashCommand(message: Message, options?: string[]): Promise<InteractionResponseBody>; public static sendSlashCommand(message: Message, subCommandArray?: string[], options?: string[]): Promise<InteractionResponseBody>;
public static sendContextMenu(message: Message): Promise<InteractionResponseBody>; public static sendContextMenu(message: Message): Promise<InteractionResponseBody>;
} }