interactions wip

This commit is contained in:
Gus Caplan 2020-11-10 12:15:07 -06:00
parent 2685b960d7
commit c6f87aa32d
No known key found for this signature in database
GPG key ID: F00BD11880E82F0E
15 changed files with 335 additions and 7 deletions

View file

@ -1,4 +1,5 @@
{
"root": true,
"extends": ["eslint:recommended", "plugin:prettier/recommended"],
"plugins": ["import"],
"parserOptions": {

View file

@ -110,9 +110,9 @@
"src/client/voice/receiver/PacketHandler.js": false,
"src/client/voice/receiver/Receiver.js": false,
"src/client/voice/util/PlayInterface.js": false,
"src/client/voice/util/Secretbox.js": false,
"src/client/voice/util/Silence.js": false,
"src/client/voice/util/VolumeInterface.js": false
"src/client/voice/util/VolumeInterface.js": false,
"src/util/Sodium.js": false
},
"husky": {
"hooks": {

View file

@ -1,6 +1,7 @@
'use strict';
const BaseClient = require('./BaseClient');
const InteractionClient = require('./InteractionClient');
const ActionsManager = require('./actions/ActionsManager');
const ClientVoiceManager = require('./voice/ClientVoiceManager');
const WebSocketManager = require('./websocket/WebSocketManager');
@ -103,6 +104,24 @@ class Client extends BaseClient {
? ShardClientUtil.singleton(this, process.env.SHARDING_MANAGER_MODE)
: null;
/**
* The interaction client.
* @type {InteractionClient}
*/
this.interactionClient = new InteractionClient(
options,
interaction => {
/**
* Emitted when an interaction is created.
* @event Client#interactionCreate
* @param {Interaction} interaction The interaction which was created.
*/
this.client.emit(Events.INTERACTION_CREATE, interaction);
return null;
},
this,
);
/**
* All of the {@link User} objects that have been cached at any point, mapped by their IDs
* @type {UserManager}

View file

@ -0,0 +1,131 @@
'use strict';
const BaseClient = require('./BaseClient');
const Interaction = require('../structures/Interaction');
const { ApplicationCommandOptionType, InteractionType, InteractionResponseType } = require('../util/Constants');
let sodium;
/**
* Interaction client is used for interactions.
*
* ```js
* const client = new InteractionClient({
* token: ABC,
* publicKey: XYZ,
* }, async (interaction) => {
* if (will take a long time) {
* doSomethingLong.then((d) => {
* interaction.reply({
* content: 'wow that took long',
* });
* });
* // return null to signal that we will be replying via `interaction.reply`.
* return null;
* }
* return { content: 'hi!' };
* });
* ```
*/
class InteractionClient extends BaseClient {
/**
* @param {Options} options Options for the client.
* @param {Handler} handler Handler to handle things.
* @param {undefined} client For internal use.
*/
constructor(options, handler, client) {
super(options);
this.client = client || this;
this.handler = handler;
this.publicKey = options.publicKey ? Buffer.from(options.publicKey, 'hex') : undefined;
}
getCommands(guildID) {
let path = this.client.api.applications('@me');
if (guildID) {
path = path.guilds(guildID);
}
return path.commands.get();
}
createCommand(command, guildID) {
let path = this.client.api.applications('@me');
if (guildID) {
path = path.guilds(guildID);
}
return path.commands.post({
data: {
name: command.name,
description: command.description,
options: command.options.map(function m(o) {
return {
type: ApplicationCommandOptionType[o.type],
name: o.name,
description: o.description,
default: o.default,
required: o.required,
choices: o.choices,
options: o.options.map(m),
};
}),
},
});
}
deleteCommand(commandID, guildID) {
let path = this.client.api.applications('@me');
if (guildID) {
path = path.guilds(guildID);
}
return path.commands(commandID).delete();
}
async handle(data) {
switch (data.type) {
case InteractionType.PING:
return {
type: InteractionResponseType.PONG,
};
case InteractionType.APPLICATION_COMMAND: {
try {
const interaction = new Interaction(this.client, data);
const result = await this.handler(interaction);
if (result === null) {
return {
type: InteractionResponseType.ACKNOWLEDGE,
};
}
// handle result as message resolvable here, probably DRY this branch with code in
// `Interaction#reply`, except `Interaction#reply` obviously does a POST and this just
// returns the data.
throw new Error('fucc');
} catch (e) {
this.client.emit('error', e);
return {
type: InteractionResponseType.ACKNOWLEDGE,
};
}
}
default:
throw new RangeError('Invalid interaction data');
}
}
async handleFromHTTP(body, signature) {
if (sodium === undefined) {
sodium = require('../util/Sodium');
}
if (!sodium.methods.verify(Buffer.from(signature, 'hex'), Buffer.from(body), this.publicKey)) {
throw new Error('Invalid signature');
}
const data = JSON.parse(body);
const result = await this.handle(data);
return JSON.stringify(result);
}
async handleFromGateway(data) {
await this.handle(data);
}
}
module.exports = InteractionClient;

View file

@ -37,6 +37,7 @@ class ActionsManager {
this.register(require('./GuildIntegrationsUpdate'));
this.register(require('./WebhooksUpdate'));
this.register(require('./TypingStart'));
this.register(require('./InteractionCreate'));
}
register(Action) {

View file

@ -0,0 +1,15 @@
'use strict';
const Action = require('./Action');
class InteractionCreateAction extends Action {
handle(data) {
this.client.interactionClient.handleFromGateway(data).catch(e => {
this.client.emit('error', e);
});
return {};
}
}
module.exports = InteractionCreateAction;

View file

@ -1,7 +1,7 @@
'use strict';
const { Writable } = require('stream');
const secretbox = require('../util/Secretbox');
const secretbox = require('../../../util/Sodium');
const Silence = require('../util/Silence');
const VolumeInterface = require('../util/VolumeInterface');

View file

@ -1,8 +1,8 @@
'use strict';
const EventEmitter = require('events');
const sodium = require('../../../util/Sodium');
const Speaking = require('../../../util/Speaking');
const secretbox = require('../util/Secretbox');
const { SILENCE_FRAME } = require('../util/Silence');
// The delay between packets when a user is considered to have stopped speaking
@ -58,7 +58,7 @@ class PacketHandler extends EventEmitter {
}
// Open packet
let packet = secretbox.methods.open(buffer.slice(12, end), this.nonce, secret_key);
let packet = sodium.methods.open(buffer.slice(12, end), this.nonce, secret_key);
if (!packet) return new Error('Failed to decrypt voice packet');
packet = Buffer.from(packet);

View file

@ -0,0 +1,5 @@
'use strict';
module.exports = (client, packet) => {
client.actions.InteractionCreate.handle(packet.d);
};

View file

@ -6,6 +6,7 @@ module.exports = {
// "Root" classes (starting points)
BaseClient: require('./client/BaseClient'),
Client: require('./client/Client'),
InteractionClient: require('./client/InteractionClient'),
Shard: require('./sharding/Shard'),
ShardClientUtil: require('./sharding/ShardClientUtil'),
ShardingManager: require('./sharding/ShardingManager'),

View file

@ -74,6 +74,16 @@ class APIMessage {
return this.target instanceof Message;
}
/**
* Whether or not the target is an interaction
* @type {boolean}
* @readonly
*/
get isInteraction() {
const Interaction = require('./Interaction');
return this.target instanceof Interaction;
}
/**
* Makes the content of this message.
* @returns {?(string|string[])}
@ -166,6 +176,8 @@ class APIMessage {
if (this.isMessage) {
// eslint-disable-next-line eqeqeq
flags = this.options.flags != null ? new MessageFlags(this.options.flags).bitfield : this.target.flags.bitfield;
} else if (this.isInteraction) {
flags = this.options.ephemeral ? MessageFlags.EPHEMERAL : undefined;
}
let allowedMentions =

View file

@ -0,0 +1,112 @@
'use strict';
const APIMessage = require('./APIMessage');
const Base = require('./Base');
const { InteractionResponseType } = require('../util/Constants');
const Snowflake = require('../util/Snowflake');
/**
* Represents an interaction, see {@link InteractionClient}.
* @extends {Base}
*/
class Interaction extends Base {
constructor(client, data) {
super(client);
this._patch(data);
}
_patch(data) {
/**
* The ID of this interaction.
* @type {Snowflake}
*/
this.id = data.id;
/**
* The token of this interaction.
* @type {string}
*/
this.token = data.token;
/**
* The ID of the invoked command.
* @type {Snowflake}
*/
this.commandID = data.data.id;
/**
* The name of the invoked command.
* @type {string}
*/
this.commandName = data.data.name;
/**
* The options passed to the command.
* @type {Object}
*/
this.options = data.data.options;
/**
* The channel this interaction was sent in.
* @type {Channel}
*/
this.channel = this.client.channels.cache.get(data.channel_id);
/**
* The guild this interaction was sent in, if any.
* @type {?Guild}
*/
this.guild = data.guild_id ? this.client.guilds.cache.get(data.guild_id) : null;
/**
* If this interaction was sent in a guild, the member which sent it.
* @type {?Member}
*/
this.member = data.member ? this.guild.members.add(data.member, false) : null;
}
/**
* The timestamp the emoji was created at, or null if unicode
* @type {?number}
* @readonly
*/
get createdTimestamp() {
if (!this.id) return null;
return Snowflake.deconstruct(this.id).timestamp;
}
/**
* The time the emoji was created at, or null if unicode
* @type {?Date}
* @readonly
*/
get createdAt() {
if (!this.id) return null;
return new Date(this.createdTimestamp);
}
async reply(content, options) {
let apiMessage;
if (content instanceof APIMessage) {
apiMessage = content.resolveData();
} else {
apiMessage = APIMessage.create(this, content, options).resolveData();
if (Array.isArray(apiMessage.data.content)) {
throw new Error();
}
}
const { data, files } = await apiMessage.resolveFiles();
return this.client.api.interactions(this.id, this.token).callback.post({
data: {
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data,
},
files,
});
}
}
module.exports = Interaction;

View file

@ -77,14 +77,14 @@ exports.DefaultOptions = {
/**
* HTTP options
* @typedef {Object} HTTPOptions
* @property {number} [version=7] API version to use
* @property {number} [version=8] API version to use
* @property {string} [api='https://discord.com/api'] Base url of the API
* @property {string} [cdn='https://cdn.discordapp.com'] Base url of the CDN
* @property {string} [invite='https://discord.gg'] Base url of invites
* @property {string} [template='https://discord.new'] Base url of templates
*/
http: {
version: 7,
version: 8,
api: 'https://discord.com/api',
cdn: 'https://cdn.discordapp.com',
invite: 'https://discord.gg',
@ -282,6 +282,7 @@ exports.Events = {
SHARD_READY: 'shardReady',
SHARD_RESUME: 'shardResume',
INVALIDATED: 'invalidated',
INTERACTION_CREATE: 'interactionCreate',
RAW: 'raw',
};
@ -345,6 +346,7 @@ exports.PartialTypes = keyMirror(['USER', 'CHANNEL', 'GUILD_MEMBER', 'MESSAGE',
* * VOICE_STATE_UPDATE
* * VOICE_SERVER_UPDATE
* * WEBHOOKS_UPDATE
* * INTERACTION_CREATE
* @typedef {string} WSEventType
*/
exports.WSEvents = keyMirror([
@ -384,6 +386,7 @@ exports.WSEvents = keyMirror([
'VOICE_STATE_UPDATE',
'VOICE_SERVER_UPDATE',
'WEBHOOKS_UPDATE',
'INTERACTION_CREATE',
]);
/**
@ -676,6 +679,29 @@ exports.WebhookTypes = [
'Channel Follower',
];
exports.ApplicationCommandOptionType = {
SUB_COMMAND: 1,
SUB_COMMAND_GROUP: 2,
STRING: 3,
INTEGER: 4,
BOOLEAN: 5,
USER: 6,
CHANNEL: 7,
ROLE: 8,
};
exports.InteractionType = {
PING: 1,
APPLICATION_COMMAND: 2,
};
exports.InteractionResponseType = {
PONG: 1,
ACKNOWLEDGE: 2,
CHANNEL_MESSAGE: 3,
CHANNEL_MESSAGE_WITH_SOURCE: 4,
};
function keyMirror(arr) {
let tmp = Object.create(null);
for (const value of arr) tmp[value] = value;

View file

@ -22,6 +22,7 @@ class MessageFlags extends BitField {}
* * `SUPPRESS_EMBEDS`
* * `SOURCE_MESSAGE_DELETED`
* * `URGENT`
* * `EPHEMERAL`
* @type {Object}
* @see {@link https://discord.com/developers/docs/resources/channel#message-object-message-flags}
*/
@ -31,6 +32,7 @@ MessageFlags.FLAGS = {
SUPPRESS_EMBEDS: 1 << 2,
SOURCE_MESSAGE_DELETED: 1 << 3,
URGENT: 1 << 4,
EPHEMERAL: 1 << 6,
};
module.exports = MessageFlags;

View file

@ -5,16 +5,19 @@ const libs = {
open: sodium.api.crypto_secretbox_open_easy,
close: sodium.api.crypto_secretbox_easy,
random: n => sodium.randombytes_buf(n),
verify: sodium.api.crypto_sign_verify_detached,
}),
'libsodium-wrappers': sodium => ({
open: sodium.crypto_secretbox_open_easy,
close: sodium.crypto_secretbox_easy,
random: n => sodium.randombytes_buf(n),
verify: sodium.crypto_sign_verify_detached,
}),
tweetnacl: tweetnacl => ({
open: tweetnacl.secretbox.open,
close: tweetnacl.secretbox,
random: n => tweetnacl.randomBytes(n),
verify: (s, d, p) => tweetnacl.sign.detached.verify(d, s, p),
}),
};