Update TextBasedChannel.js

This commit is contained in:
Elysia 2024-01-10 19:31:44 +07:00
parent 4a1d615673
commit a4ff0fd12d

View File

@ -1,14 +1,17 @@
'use strict'; 'use strict';
/* eslint-disable import/order */ /* eslint-disable import/order */
const InteractionManager = require('../../managers/InteractionManager');
const MessageCollector = require('../MessageCollector'); const MessageCollector = require('../MessageCollector');
const MessagePayload = require('../MessagePayload'); const MessagePayload = require('../MessagePayload');
const { InteractionTypes, ApplicationCommandOptionTypes, Events } = require('../../util/Constants');
const { Error } = require('../../errors');
const SnowflakeUtil = require('../../util/SnowflakeUtil'); const SnowflakeUtil = require('../../util/SnowflakeUtil');
const { setTimeout } = require('node:timers'); const { Collection } = require('@discordjs/collection');
const { InteractionTypes, MaxBulkDeletableMessageAge } = require('../../util/Constants');
const { TypeError, Error } = require('../../errors');
const InteractionCollector = require('../InteractionCollector');
const { lazy, getAttachments, uploadFile } = require('../../util/Util');
const Message = lazy(() => require('../Message').Message);
const { s } = require('@sapphire/shapeshift'); const { s } = require('@sapphire/shapeshift');
const Util = require('../../util/Util');
const validateName = stringName => const validateName = stringName =>
s.string s.string
.lengthGreaterThanOrEqual(1) .lengthGreaterThanOrEqual(1)
@ -29,6 +32,12 @@ class TextBasedChannel {
*/ */
this.messages = new MessageManager(this); this.messages = new MessageManager(this);
/**
* A manager of the interactions sent to this channel
* @type {InteractionManager}
*/
this.interactions = new InteractionManager(this);
/** /**
* The channel's last message id, if one was sent * The channel's last message id, if one was sent
* @type {?Snowflake} * @type {?Snowflake}
@ -67,7 +76,7 @@ class TextBasedChannel {
* @property {boolean} [tts=false] Whether or not the message should be spoken aloud * @property {boolean} [tts=false] Whether or not the message should be spoken aloud
* @property {string} [nonce=''] The nonce for the message * @property {string} [nonce=''] The nonce for the message
* @property {string} [content=''] The content for the message * @property {string} [content=''] The content for the message
* @property {Array<(MessageEmbed|APIEmbed)>} [embeds] The embeds for the message * @property {Array<(MessageEmbed|APIEmbed|WebEmbed)>} [embeds] The embeds for the message
* (see [here](https://discord.com/developers/docs/resources/channel#embed-object) for more details) * (see [here](https://discord.com/developers/docs/resources/channel#embed-object) for more details)
* @property {MessageMentionOptions} [allowedMentions] Which mentions should be parsed from the message content * @property {MessageMentionOptions} [allowedMentions] Which mentions should be parsed from the message content
* (see [here](https://discord.com/developers/docs/resources/channel#allowed-mentions-object) for more details) * (see [here](https://discord.com/developers/docs/resources/channel#allowed-mentions-object) for more details)
@ -75,6 +84,7 @@ class TextBasedChannel {
* @property {Array<(MessageActionRow|MessageActionRowOptions)>} [components] * @property {Array<(MessageActionRow|MessageActionRowOptions)>} [components]
* Action rows containing interactive components for the message (buttons, select menus) * Action rows containing interactive components for the message (buttons, select menus)
* @property {MessageAttachment[]} [attachments] Attachments to send in the message * @property {MessageAttachment[]} [attachments] Attachments to send in the message
* @property {boolean} [usingNewAttachmentAPI] Whether to use the new attachment API (`channels/:id/attachments`)
*/ */
/** /**
@ -158,197 +168,41 @@ class TextBasedChannel {
let messagePayload; let messagePayload;
if (options instanceof MessagePayload) { if (options instanceof MessagePayload) {
messagePayload = options.resolveData(); messagePayload = await options.resolveData();
} else { } else {
messagePayload = MessagePayload.create(this, options).resolveData(); messagePayload = await MessagePayload.create(this, options).resolveData();
} }
const { data, files } = await messagePayload.resolveFiles(); let { data, files } = await messagePayload.resolveFiles();
// New API
const attachments = await Util.getUploadURL(this.client, this.id, files); if (typeof options == 'object' && typeof options.usingNewAttachmentAPI !== 'boolean') {
const requestPromises = attachments.map(async attachment => { options.usingNewAttachmentAPI = this.client.options.usingNewAttachmentAPI;
await Util.uploadFile(files[attachment.id].file, attachment.upload_url); }
return {
id: attachment.id, if (options?.usingNewAttachmentAPI === true) {
filename: files[attachment.id].name, const attachments = await getAttachments(this.client, this.id, ...files);
uploaded_filename: attachment.upload_filename, const requestPromises = attachments.map(async attachment => {
description: files[attachment.id].description, await uploadFile(files[attachment.id].file, attachment.upload_url);
duration_secs: files[attachment.id].duration_secs, return {
waveform: files[attachment.id].waveform, id: attachment.id,
}; filename: files[attachment.id].name,
}); uploaded_filename: attachment.upload_filename,
const attachmentsData = await Promise.all(requestPromises); description: files[attachment.id].description,
attachmentsData.sort((a, b) => parseInt(a.id) - parseInt(b.id)); duration_secs: files[attachment.id].duration_secs,
data.attachments = attachmentsData; waveform: files[attachment.id].waveform,
// Empty Files };
const d = await this.client.api.channels[this.id].messages.post({ data }); });
const attachmentsData = await Promise.all(requestPromises);
attachmentsData.sort((a, b) => parseInt(a.id) - parseInt(b.id));
data.attachments = attachmentsData;
files = [];
}
const d = await this.client.api.channels[this.id].messages.post({ data, files });
return this.messages.cache.get(d.id) ?? this.messages._add(d); return this.messages.cache.get(d.id) ?? this.messages._add(d);
} }
searchInteraction() {
// Support Slash / ContextMenu
// API https://canary.discord.com/api/v9/guilds/:id/application-command-index // Guild
// https://canary.discord.com/api/v9/channels/:id/application-command-index // DM Channel
// Updated: 07/01/2023
return this.client.api[this.guild ? 'guilds' : 'channels'][this.guild?.id || this.id][
'application-command-index'
].get();
}
async sendSlash(botOrApplicationId, commandNameString, ...args) {
// Parse commandName /role add user
const cmd = commandNameString.trim().split(' ');
// Ex: role add user => [role, add, user]
// Parse: name, subGr, sub
const commandName = validateName(cmd[0]);
// Parse: role
const sub = cmd.slice(1);
// Parse: [add, user]
for (let i = 0; i < sub.length; i++) {
if (sub.length > 2) {
throw new Error('INVALID_COMMAND_NAME', cmd);
}
validateName(sub[i]);
}
// Search all
const data = await this.searchInteraction();
// Find command...
const filterCommand = data.application_commands.filter(obj =>
// Filter: name | name_default
[obj.name, obj.name_default].includes(commandName),
);
// Filter Bot
botOrApplicationId = this.client.users.resolveId(botOrApplicationId);
const application = data.applications.find(
obj => obj.id == botOrApplicationId || obj.bot?.id == botOrApplicationId,
);
// Find Command with application
const command = filterCommand.find(command => command.application_id == application.id);
args = args.flat(2);
let optionFormat = [];
let attachments = [];
let optionsMaxdepth, subGroup, subCommand;
if (sub.length == 2) {
// Subcommand Group > Subcommand
// Find Sub group
subGroup = command.options.find(
obj =>
obj.type == ApplicationCommandOptionTypes.SUB_COMMAND_GROUP && [obj.name, obj.name_default].includes(sub[0]),
);
if (!subGroup) throw new Error('SLASH_COMMAND_SUB_COMMAND_GROUP_INVALID', sub[0]);
// Find Sub
subCommand = subGroup.options.find(
obj => obj.type == ApplicationCommandOptionTypes.SUB_COMMAND && [obj.name, obj.name_default].includes(sub[1]),
);
if (!subCommand) throw new Error('SLASH_COMMAND_SUB_COMMAND_INVALID', sub[1]);
// Options
optionsMaxdepth = subCommand.options;
} else if (sub.length == 1) {
// Subcommand
subCommand = command.options.find(
obj => obj.type == ApplicationCommandOptionTypes.SUB_COMMAND && [obj.name, obj.name_default].includes(sub[0]),
);
if (!subCommand) throw new Error('SLASH_COMMAND_SUB_COMMAND_INVALID', sub[0]);
// Options
optionsMaxdepth = subCommand.options;
} else {
optionsMaxdepth = command.options;
}
const valueRequired = optionsMaxdepth?.filter(o => o.required).length || 0;
for (let i = 0; i < Math.min(args.length, optionsMaxdepth?.length || 0); i++) {
const optionInput = optionsMaxdepth[i];
const value = args[i];
const parseData = await parseOption(
this.client,
optionInput,
value,
optionFormat,
attachments,
command,
application.id,
this.guild?.id,
this.id,
subGroup,
subCommand,
);
optionFormat = parseData.optionFormat;
attachments = parseData.attachments;
}
if (valueRequired > args.length) {
throw new Error('SLASH_COMMAND_REQUIRED_OPTIONS_MISSING', valueRequired, optionFormat.length);
}
// Post
let postData;
if (subGroup) {
postData = [
{
type: ApplicationCommandOptionTypes.SUB_COMMAND_GROUP,
name: subGroup.name,
options: [
{
type: ApplicationCommandOptionTypes.SUB_COMMAND,
name: subCommand.name,
options: optionFormat,
},
],
},
];
} else if (subCommand) {
postData = [
{
type: ApplicationCommandOptionTypes.SUB_COMMAND,
name: subCommand.name,
options: optionFormat,
},
];
} else {
postData = optionFormat;
}
const nonce = SnowflakeUtil.generate();
const body = createPostData(
this.client,
false,
application.id,
nonce,
this.guild?.id,
Boolean(command.guild_id),
this.id,
command.version,
command.id,
command.name_default || command.name,
command.type,
postData,
attachments,
);
this.client.api.interactions.post({
data: body,
usePayloadJSON: true,
});
return new Promise((resolve, reject) => {
const timeoutMs = 5_000;
// Waiting for MsgCreate / ModalCreate
const handler = data => {
if (data.nonce !== nonce) return;
clearTimeout(timeout);
this.client.removeListener(Events.MESSAGE_CREATE, handler);
this.client.removeListener(Events.INTERACTION_MODAL_CREATE, handler);
this.client.decrementMaxListeners();
resolve(data);
};
const timeout = setTimeout(() => {
this.client.removeListener(Events.MESSAGE_CREATE, handler);
this.client.removeListener(Events.INTERACTION_MODAL_CREATE, handler);
this.client.decrementMaxListeners();
reject(new Error('INTERACTION_FAILED'));
}, timeoutMs).unref();
this.client.incrementMaxListeners();
this.client.on(Events.MESSAGE_CREATE, handler);
this.client.on(Events.INTERACTION_MODAL_CREATE, handler);
});
}
/** /**
* Sends a typing indicator in the channel. * Sends a typing indicator in the channel.
* @returns {Promise<void>} Resolves upon the typing status being sent * @returns {Promise<void>} Resolves upon the typing status being sent
@ -407,6 +261,101 @@ class TextBasedChannel {
}); });
} }
/**
* Creates a component interaction collector.
* @param {MessageComponentCollectorOptions} [options={}] Options to send to the collector
* @returns {InteractionCollector}
* @example
* // Create a button interaction collector
* const filter = (interaction) => interaction.customId === 'button' && interaction.user.id === 'someId';
* const collector = channel.createMessageComponentCollector({ filter, time: 15_000 });
* collector.on('collect', i => console.log(`Collected ${i.customId}`));
* collector.on('end', collected => console.log(`Collected ${collected.size} items`));
*/
createMessageComponentCollector(options = {}) {
return new InteractionCollector(this.client, {
...options,
interactionType: InteractionTypes.MESSAGE_COMPONENT,
channel: this,
});
}
/**
* Collects a single component interaction that passes the filter.
* The Promise will reject if the time expires.
* @param {AwaitMessageComponentOptions} [options={}] Options to pass to the internal collector
* @returns {Promise<MessageComponentInteraction>}
* @example
* // Collect a message component interaction
* const filter = (interaction) => interaction.customId === 'button' && interaction.user.id === 'someId';
* channel.awaitMessageComponent({ filter, time: 15_000 })
* .then(interaction => console.log(`${interaction.customId} was clicked!`))
* .catch(console.error);
*/
awaitMessageComponent(options = {}) {
const _options = { ...options, max: 1 };
return new Promise((resolve, reject) => {
const collector = this.createMessageComponentCollector(_options);
collector.once('end', (interactions, reason) => {
const interaction = interactions.first();
if (interaction) resolve(interaction);
else reject(new Error('INTERACTION_COLLECTOR_ERROR', reason));
});
});
}
/**
* Bulk deletes given messages that are newer than two weeks.
* @param {Collection<Snowflake, Message>|MessageResolvable[]|number} messages
* Messages or number of messages to delete
* @param {boolean} [filterOld=false] Filter messages to remove those which are older than two weeks automatically
* @returns {Promise<Collection<Snowflake, Message|undefined>>} Returns the deleted messages
* @example
* // Bulk delete messages
* channel.bulkDelete(5)
* .then(messages => console.log(`Bulk deleted ${messages.size} messages`))
* .catch(console.error);
*/
async bulkDelete(messages, filterOld = false) {
if (!this.client.user.bot) throw new Error('INVALID_USER_METHOD');
if (Array.isArray(messages) || messages instanceof Collection) {
let messageIds = messages instanceof Collection ? [...messages.keys()] : messages.map(m => m.id ?? m);
if (filterOld) {
messageIds = messageIds.filter(id => Date.now() - SnowflakeUtil.timestampFrom(id) < MaxBulkDeletableMessageAge);
}
if (messageIds.length === 0) return new Collection();
if (messageIds.length === 1) {
await this.client.api.channels(this.id).messages(messageIds[0]).delete();
const message = this.client.actions.MessageDelete.getMessage(
{
message_id: messageIds[0],
},
this,
);
return message ? new Collection([[message.id, message]]) : new Collection();
}
await this.client.api.channels[this.id].messages['bulk-delete'].post({ data: { messages: messageIds } });
return messageIds.reduce(
(col, id) =>
col.set(
id,
this.client.actions.MessageDeleteBulk.getMessage(
{
message_id: id,
},
this,
),
),
new Collection(),
);
}
if (!isNaN(messages)) {
const msgs = await this.messages.fetch({ limit: messages });
return this.bulkDelete(msgs, filterOld);
}
throw new TypeError('MESSAGE_BULK_DELETE_TYPE');
}
/** /**
* Fetches all webhooks for the channel. * Fetches all webhooks for the channel.
* @returns {Promise<Collection<Snowflake, Webhook>>} * @returns {Promise<Collection<Snowflake, Webhook>>}
@ -465,21 +414,142 @@ class TextBasedChannel {
return this.edit({ nsfw }, reason); return this.edit({ nsfw }, reason);
} }
/**
* Search Slash Command (return raw data)
* @param {Snowflake} applicationId Application ID
* @param {?ApplicationCommandType} type Command Type
* @returns {Object}
*/
searchInteraction(applicationId, type = 'CHAT_INPUT') {
switch (type) {
case 'USER':
case 2:
type = 2;
break;
case 'MESSAGE':
case 3:
type = 3;
break;
default:
type = 1;
break;
}
return this.client.api.channels[this.id]['application-commands'].search.get({
query: {
type,
application_id: applicationId,
},
});
}
/**
* Send Slash to this channel
* @param {UserResolvable} bot Bot user (BotID, not applicationID)
* @param {string} commandString Command name (and sub / group formats)
* @param {...?any|any[]} args Command arguments
* @returns {Promise<InteractionResponse>}
* @example
* // Send a basic slash
* channel.sendSlash('botid', 'ping')
* .then(console.log)
* .catch(console.error);
* @example
* // Send a remote file
* channel.sendSlash('botid', 'emoji upload', 'https://cdn.discordapp.com/icons/222078108977594368/6e1019b3179d71046e463a75915e7244.png?size=2048', 'test')
* .then(console.log)
* .catch(console.error);
* @see {@link https://github.com/aiko-chan-ai/discord.js-selfbot-v13/blob/main/Document/SlashCommand.md}
*/
async sendSlash(bot, commandString, ...args) {
const perms =
this.type != 'DM'
? this.permissionsFor(this.client.user).toArray()
: ['USE_APPLICATION_COMMANDS', `${this.recipient.relationships == 'BLOCKED' ? '' : 'SEND_MESSAGES'}`];
if (!perms.includes('SEND_MESSAGES')) {
throw new Error(
'INTERACTION_SEND_FAILURE',
`Cannot send Slash to ${this.toString()} ${
this.recipient ? 'because bot has been blocked' : 'due to missing SEND_MESSAGES permission'
}`,
);
}
if (!perms.includes('USE_APPLICATION_COMMANDS')) {
throw new Error(
'INTERACTION_SEND_FAILURE',
`Cannot send Slash to ${this.toString()} due to missing USE_APPLICATION_COMMANDS permission`,
);
}
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 (!bot) throw new Error('MUST_SPECIFY_BOT');
const botId = this.client.users.resolveId(bot);
const user = await this.client.users.fetch(botId).catch(() => {});
if (!user || !user.bot || !user.application) {
throw new Error('botId is not a bot or does not have an application slash command');
}
if (user._partial) await user.getProfile().catch(() => {});
if (!commandName || typeof commandName !== 'string') throw new Error('Command name is required');
const API =
this.client.api[this.guild ? 'guilds' : 'channels'][this.guild?.id || this.id]['application-command-index'];
const data = await API.get();
for (const command of data.application_commands) {
if (command.type !== 1) continue;
if (user.id == command.application_id || user.application.id == command.application_id) {
user.application?.commands?._add(command, true);
}
}
// Remove
const commandTarget = user.application?.commands?.cache.find(
c => c.name === commandName && c.type === 'CHAT_INPUT',
);
if (!commandTarget) {
throw new Error(
'INTERACTION_SEND_FAILURE',
`SlashCommand ${commandName} is not found (With search)\nDebug:\n+ botId: ${botId} (ApplicationId: ${
user.application?.id
})\n+ args: ${args.join(' | ') || null}`,
);
}
return commandTarget.sendSlashCommand(
new (Message())(this.client, {
channel_id: this.id,
guild_id: this.guild?.id || null,
author: this.client.user,
content: '',
id: this.client.user.id,
}),
sub && sub.length > 0 ? sub : [],
args && args.length ? args : [],
);
}
static applyToClass(structure, full = false, ignore = []) { static applyToClass(structure, full = false, ignore = []) {
const props = ['send']; const props = ['send'];
if (full) { if (full) {
props.push( props.push(
'sendSlash',
'searchInteraction',
'lastMessage', 'lastMessage',
'lastPinAt', 'lastPinAt',
'bulkDelete',
'sendTyping', 'sendTyping',
'createMessageCollector', 'createMessageCollector',
'awaitMessages', 'awaitMessages',
'createMessageComponentCollector',
'awaitMessageComponent',
'fetchWebhooks', 'fetchWebhooks',
'createWebhook', 'createWebhook',
'setRateLimitPerUser', 'setRateLimitPerUser',
'setNSFW', 'setNSFW',
'sendSlash',
'searchInteraction',
); );
} }
for (const prop of props) { for (const prop of props) {
@ -497,225 +567,3 @@ module.exports = TextBasedChannel;
// Fixes Circular // Fixes Circular
const MessageManager = require('../../managers/MessageManager'); const MessageManager = require('../../managers/MessageManager');
// Utils
function parseChoices(parent, list_choices, value) {
if (value !== undefined) {
if (Array.isArray(list_choices) && list_choices.length) {
const choice = list_choices.find(c => [c.name, c.value].includes(value));
if (choice) {
return choice.value;
} else {
throw new Error('INVALID_SLASH_COMMAND_CHOICES', parent, value);
}
} else {
return value;
}
} else {
return undefined;
}
}
async function addDataFromAttachment(value, client, channelId, attachments) {
value = await MessagePayload.resolveFile(value);
if (!value?.file) {
throw new TypeError('The attachment data must be a BufferResolvable or Stream or FileOptions of MessageAttachment');
}
const data = await Util.getUploadURL(client, channelId, [value]);
await Util.uploadFile(value.file, data[0].upload_url);
const id = attachments.length;
attachments.push({
id,
filename: value.name,
uploaded_filename: data[0].upload_filename,
});
return {
id,
attachments,
};
}
async function parseOption(
client,
optionCommand,
value,
optionFormat,
attachments,
command,
applicationId,
guildId,
channelId,
subGroup,
subCommand,
) {
const data = {
type: optionCommand.type,
name: optionCommand.name,
};
if (value !== undefined) {
switch (optionCommand.type) {
case ApplicationCommandOptionTypes.BOOLEAN:
case 'BOOLEAN': {
data.value = Boolean(value);
break;
}
case ApplicationCommandOptionTypes.INTEGER:
case 'INTEGER': {
data.value = Number(value);
break;
}
case ApplicationCommandOptionTypes.ATTACHMENT:
case 'ATTACHMENT': {
const parseData = await addDataFromAttachment(value, client, channelId, attachments);
data.value = parseData.id;
attachments = parseData.attachments;
break;
}
case ApplicationCommandOptionTypes.SUB_COMMAND_GROUP:
case 'SUB_COMMAND_GROUP': {
break;
}
default: {
value = parseChoices(optionCommand.name, optionCommand.choices, value);
if (optionCommand.autocomplete) {
const nonce = SnowflakeUtil.generate();
// Post
let postData;
if (subGroup) {
postData = [
{
type: ApplicationCommandOptionTypes.SUB_COMMAND_GROUP,
name: subGroup.name,
options: [
{
type: ApplicationCommandOptionTypes.SUB_COMMAND,
name: subCommand.name,
options: [
{
type: optionCommand.type,
name: optionCommand.name,
value,
focused: true,
},
],
},
],
},
];
} else if (subCommand) {
postData = [
{
type: ApplicationCommandOptionTypes.SUB_COMMAND,
name: subCommand.name,
options: [
{
type: optionCommand.type,
name: optionCommand.name,
value,
focused: true,
},
],
},
];
} else {
postData = [
{
type: optionCommand.type,
name: optionCommand.name,
value,
focused: true,
},
];
}
const body = createPostData(
client,
true,
applicationId,
nonce,
guildId,
Boolean(command.guild_id),
channelId,
command.version,
command.id,
command.name_default || command.name,
command.type,
postData,
[],
);
await client.api.interactions.post({
data: body,
});
data.value = await awaitAutocomplete(client, nonce, value);
} else {
data.value = value;
}
}
}
optionFormat.push(data);
}
return {
optionFormat,
attachments,
};
}
function awaitAutocomplete(client, nonce, defaultValue) {
return new Promise(resolve => {
const handler = data => {
if (data.t !== 'APPLICATION_COMMAND_AUTOCOMPLETE_RESPONSE') return;
if (data.d?.nonce !== nonce) return;
clearTimeout(timeout);
client.removeListener(Events.UNHANDLED_PACKET, handler);
client.decrementMaxListeners();
if (data.d.choices.length >= 1) {
resolve(data.d.choices[0].value);
} else {
resolve(defaultValue);
}
};
const timeout = setTimeout(() => {
client.removeListener(Events.UNHANDLED_PACKET, handler);
client.decrementMaxListeners();
resolve(defaultValue);
}, 5_000).unref();
client.incrementMaxListeners();
client.on(Events.UNHANDLED_PACKET, handler);
});
}
function createPostData(
client,
isAutocomplete = false,
applicationId,
nonce,
guildId,
isGuildCommand,
channelId,
commandVersion,
commandId,
commandName,
commandType,
postData,
attachments = [],
) {
const data = {
type: isAutocomplete ? InteractionTypes.APPLICATION_COMMAND_AUTOCOMPLETE : InteractionTypes.APPLICATION_COMMAND,
application_id: applicationId,
guild_id: guildId,
channel_id: channelId,
session_id: client.ws.shards.first()?.sessionId,
data: {
version: commandVersion,
id: commandId,
name: commandName,
type: commandType,
options: postData,
attachments: attachments,
},
nonce,
};
if (isGuildCommand) {
data.data.guild_id = guildId;
}
return data;
}