Skip to content

Commit

Permalink
interactions
Browse files Browse the repository at this point in the history
  • Loading branch information
devsnek committed Dec 18, 2020
1 parent ffe3140 commit 1de89f0
Show file tree
Hide file tree
Showing 17 changed files with 567 additions and 5 deletions.
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"root": true,
"extends": ["eslint:recommended", "plugin:prettier/recommended"],
"plugins": ["import"],
"parserOptions": {
Expand Down
7 changes: 7 additions & 0 deletions src/client/Client.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -102,6 +103,12 @@ class Client extends BaseClient {
? ShardClientUtil.singleton(this, process.env.SHARDING_MANAGER_MODE)
: null;

/**
* The interaction client.
* @type {InteractionClient}
*/
this.interactionClient = new InteractionClient(options, this);

/**
* All of the {@link User} objects that have been cached at any point, mapped by their IDs
* @type {UserManager}
Expand Down
211 changes: 211 additions & 0 deletions src/client/InteractionClient.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
'use strict';

const BaseClient = require('./BaseClient');
const ApplicationCommand = require('../structures/ApplicationCommand');
const CommandInteraction = require('../structures/CommandInteraction');
const { Events, ApplicationCommandOptionType, InteractionType, InteractionResponseType } = require('../util/Constants');

let sodium;

/**
* Interaction client is used for interactions.
*
* @example
* const client = new InteractionClient({
* token: ABC,
* publicKey: XYZ,
* });
*
* client.on('interactionCreate', () => {
* // automatically handles long responses
* if (will take a long time) {
* doSomethingLong.then((d) => {
* interaction.reply({
* content: 'wow that took long',
* });
* });
* } else {
* interaction.reply('hi!');
* }
* });
* ```
*/
class InteractionClient extends BaseClient {
/**
* @param {Options} options Options for the client.
* @param {undefined} client For internal use.
*/
constructor(options, client) {
super(options);

Object.defineProperty(this, 'token', {
value: options.token,
writable: true,
});

Object.defineProperty(this, 'clientID', {
value: options.clientID,
writable: true,
});

Object.defineProperty(this, 'publicKey', {
value: options.publicKey ? Buffer.from(options.publicKey, 'hex') : undefined,
writable: true,
});

// Compat for direct usage
this.client = client || this;
this.interactionClient = this;
}

/**
* Fetch registered slash commands.
* @param {Snowflake} [guildID] Optional guild ID.
* @returns {Command[]}
*/
async fetchCommands(guildID) {
let path = this.client.api.applications('@me');
if (guildID) {
path = path.guilds(guildID);
}
const commands = await path.commands.get();
return commands.map(c => new ApplicationCommand(this, c, guildID));
}

/**
* Create a command.
* @param {Object} command The command description.
* @param {Snowflake} [guildID] Optional guild ID.
* @returns {ApplicationCommand} The created command.
*/
async createCommand(command, guildID) {
let path = this.client.api.applications('@me');
if (guildID) {
path = path.guilds(guildID);
}
const c = await 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),
};
}),
},
});
return new ApplicationCommand(this, c, guildID);
}

handle(data) {
switch (data.type) {
case InteractionType.PING:
return {
type: InteractionResponseType.PONG,
};
case InteractionType.APPLICATION_COMMAND: {
let timedOut = false;
let resolve;
const directPromise = new Promise(r => {
resolve = r;
this.client.setTimeout(() => {
timedOut = true;
r({
type: InteractionResponseType.ACKNOWLEDGE_WITH_SOURCE,
});
}, 250);
});

const syncHandle = {
acknowledge({ hideSource }) {
if (!timedOut) {
resolve({
type: hideSource
? InteractionResponseType.ACKNOWLEDGE
: InteractionResponseType.ACKNOWLEDGE_WITH_SOURCE,
});
}
},
reply(resolved) {
if (timedOut) {
return false;
}
resolve({
type: resolved.hideSource
? InteractionResponseType.CHANNEL_MESSAGE
: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: resolved.data,
});
return true;
},
};

const interaction = new CommandInteraction(this.client, data, syncHandle);

/**
* 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 directPromise;
}
default:
throw new RangeError('Invalid interaction data');
}
}

/**
* An express-like middleware factory which can be used
* with webhook interactions.
* @returns {Function} The middleware function.
*/
middleware() {
return async (req, res) => {
const timestamp = req.get('x-signature-timestamp');
const signature = req.get('x-signature-ed25519');

const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const body = Buffer.concat(chunks);

if (sodium === undefined) {
sodium = require('../util/Sodium');
}
if (
!sodium.methods.verify(
Buffer.from(signature, 'hex'),
Buffer.concat([Buffer.from(timestamp), body]),
this.publicKey,
)
) {
res.status(401).end();
return;
}

const data = JSON.parse(body.toString());

const result = await this.handle(data);
res.status(200).end(JSON.stringify(result));
};
}

async handleFromGateway(data) {
const result = await this.handle(data);

await this.client.api.interactions(data.id, data.token).callback.post({
data: result,
});
}
}

module.exports = InteractionClient;
1 change: 1 addition & 0 deletions src/client/actions/ActionsManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class ActionsManager {
this.register(require('./GuildIntegrationsUpdate'));
this.register(require('./WebhooksUpdate'));
this.register(require('./TypingStart'));
this.register(require('./InteractionCreate'));
}

register(Action) {
Expand Down
15 changes: 15 additions & 0 deletions src/client/actions/InteractionCreate.js
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion src/client/voice/dispatcher/StreamDispatcher.js
Original file line number Diff line number Diff line change
@@ -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');

Expand Down
4 changes: 2 additions & 2 deletions src/client/voice/receiver/PacketHandler.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);

Expand Down
5 changes: 5 additions & 0 deletions src/client/websocket/handlers/INTERACTION_CREATE.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use strict';

module.exports = (client, packet) => {
client.actions.InteractionCreate.handle(packet.d);
};
4 changes: 4 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ module.exports = {
// "Root" classes (starting points)
BaseClient: require('./client/BaseClient'),
Client: require('./client/Client'),
CommandInteraction: require('./structures/CommandInteraction'),
InteractionClient: require('./client/InteractionClient'),
Shard: require('./sharding/Shard'),
ShardClientUtil: require('./sharding/ShardClientUtil'),
ShardingManager: require('./sharding/ShardingManager'),
Expand Down Expand Up @@ -58,6 +60,7 @@ module.exports = {

// Structures
Application: require('./structures/interfaces/Application'),
ApplicationCommand: require('./structures/ApplicationCommand'),
Base: require('./structures/Base'),
Activity: require('./structures/Presence').Activity,
APIMessage: require('./structures/APIMessage'),
Expand All @@ -80,6 +83,7 @@ module.exports = {
GuildPreview: require('./structures/GuildPreview'),
GuildTemplate: require('./structures/GuildTemplate'),
Integration: require('./structures/Integration'),
Interaction: require('./structures/Interaction'),
Invite: require('./structures/Invite'),
Message: require('./structures/Message'),
MessageAttachment: require('./structures/MessageAttachment'),
Expand Down
19 changes: 19 additions & 0 deletions src/structures/APIMessage.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ class APIMessage {
* @type {?Object[]}
*/
this.files = null;

/**
* Whether to hide the source of an interaction command.
* @type {boolean}
*/
this.hideSource = false;
}

/**
Expand Down Expand Up @@ -73,6 +79,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[])}
Expand Down Expand Up @@ -149,6 +165,9 @@ 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.FLAGS.EPHEMERAL : undefined;
this.hideSource = !!this.options.hideSource;
}

let allowedMentions =
Expand Down

0 comments on commit 1de89f0

Please sign in to comment.