discordjs / discord.js

A powerful JavaScript library for interacting with the Discord API
https://discord.js.org
Apache License 2.0
25.13k stars 3.95k forks source link

Proposal: Middleware API #7190

Open suneettipirneni opened 2 years ago

suneettipirneni commented 2 years ago

Feature

Providing addons that are extensions to discord.js has been possible for a great while now, however the methods of doing so aren't very standardized. In addition, the ways of doing extensions for discord.js involve things like client subclassing. Often times, a developer just wants a way to preprocess the data recieved by discord, rather than trying to create a new client based on discord.js.

Instead, a more abstract way to interact with data received by the discord should be made. Introducing discord.js middleware.

What is Middleware?

As the name implies, middleware is code that runs inbetween two other code operations. This means custom tranformations or extra logic can be injected into the standard code execution sequence. All of these injections are done in a standardized way provided by the API.

The idea of middleware is far from uncommon, many popular libraries and frameworks have ways to inject middleware.

These packages include:

In this proposal, I'm proposing an API to inject middleware between the raw websocket event and the Client.on('event') public-facing events. This allows developers to easily preprocess data before the it gets emitted to the client, which in turns allows for powerful addons.

How is this different from just attaching listeners to the client?

Event listeners aren't executed in the "middle" of anything. For example if I make a transformation in one client.on() event listener that doesn't affect the types in other client.on() listeners. Basically there's never a preprocessing phase.

Ideal solution or implementation

To represent the potential power and simplicity of discord.js middleware, I'll set up some scenarios.

Scenario 1 - Translation/Internationalization Middleware

Since discord will be releasing a way to detect locale from an interaction, I want to set up a middleware that injects translation ability into the data object sent to the API.

I can create my middleware like so:

// TranslationInteraction.ts
import { translationModule } from './translation';

export class TranslationInteraction extends CommandInteraction {
   ...
   public override reply(...) {
     // translate content
     ...
     data.content = translationModule.translate(data.content, this.locale);
     ...
   };

    // Wraps standard djs interaction.
    public static from(interaction: Interaction): TranslationInteraction { ... }
}
// Middleware.ts
import { TranslationInteraction } from "./TranslationInteraction";
import type { createMiddleware, NextFunction } from "discord.js";

export function TranslationMiddleware(
  interaction: Interaction,
  next: NextFunction<"interactionCreate">
) {
  // We're only interested in command interactions, if it's not
  // one, move on to the next middleware.
  if (!interaction.isCommand()) {
    // Pass along the interaction unmodified
    next(interaction);
    return;
  }

  // Invoke the next middleware in the pipeline with the
  // modified interaction object.
  next(TranslationInteraction.from(interaction));
}

// Wrap the middleware and export it
const Middleware = createMiddleware("interactionCreate", TranslationMiddleware);
export default Middleware;

Let's go over some of the things that happened here:

As for the user who wants to use this middleware, the setup is painless.

// index.ts

import { translationModule, TranslationMiddleware } from '@foo/translate';

// Init the translation files
translationModule.initFiles(path.join(__dirname, 'keys.json');

...

// Plug in the middleware.
client.use(TranslationMiddleware);

// handle interactions like normal.

client.on('interactionCreate', async (interaction) => {
    if (!interaction.isCommand() || interaction.commandName !== 'greet') return;

    await interaction.reply('GREET_LANG_KEY');
})

This code should result in and reply content being translated based on the key you give it. You barely even need to touch the middleware library, and you don't need to learn any new methods for your interactions.

Scenario 2 - Custom Command Handler

This one is a classic one, a basic command handler. A command handler can be middleware too. Let's see how we would accomplish that.

// middleware.ts
import { createMiddleware } from "discord.js";
import { dispatch } from "./dispatcher";

export function CommandHandlerMiddleware(interaction: Interaction, next) {
  if (!interaction.isCommand()) {
    next(interaction);
    return;
  }

  // Invoke command handler.
  dispatch(interaction.commandName);
  next(interaction);
}

const Middleware = createMiddleware(
  "interactionCreate",
  CommandHandlerMiddleware
);

export default Middleware;

Similarly to before, we can easily integrate this into our client:

// index.ts
import { CommandHandler, Middleware } from '@foo/commands';

CommandHandler.register(path.join(__dirname, 'commands'));

client.use('interactionCreate', Middleware);

...

// commands/MyCommand.ts
class MyCommand extends Command {
    constructor() { ... };
    run() { ... }
}

Just like that our command handler is fully integrated.

Ok but like what's the point of command handlers even being middleware?

Good question! The reason I gave a command handler as an example of middleware is not show that the command handler itself is better, but to instead show how it can integrate with other middleware.

Middleware works Together, not Apart

Ok great I have a translation middleware and a command middleware. But I want them to work together, I don't want to have to pick one over the other?

Good news, middleware moves in a Pipeline

The Pipeline

The pipeline works by passing information from one middleware to another in a sequential fashion. This is one of the purposes of the next function. It simply passes the information along to the next middleware in the pipeline. In essence, the pipeline enables the following:

Scenario 3 - Using Scenario 1 and 2 Together

Let's make both of these middlewares work together.

// index.ts
import { ..., TranslationMiddleware }  from  '@foo/translate';
import { ..., CommandMiddleware } from '@foo/commands';

...
// Let's use both middlewares!
client.use('interactionCreate', TranslationMiddleware, CommandMiddleware);
...

// commands/MyCommand.ts
export class MyCommand extends Command {
    ...
    run(interaction) {
        // Translations now work in my custom command handler!!
        await interaction.reply('TRANSLATION_KEY');
    }
}

All I needed to do was add TranslationMiddleware to the front of CommandMiddleware in client.use. Now it can take full advantage of the translations while not having to change anything about the command-handler middleware.

A Visual Representation

drawing

Pipeline Suspension

Pipelines can be suspended if next() is never invoked. This is useful for scenarios that cannot move on to another middleware. It's also useful for filtering events - for example, you can create explicit message filter:

  1. The explicit message filter middleware is invoked.
  2. It runs the message content through its filter and detects explicit message content.
  3. It deletes the message
  4. Bot DMs user who sent message

Note that next() is never invoked from this middleware. This means that this middleware has suspended the pipeline, this also means that the client.on('messageCreate') will never be called.

Feedback on implementation details is much appreciated.

Alternative solutions or implementations

No response

Other context

No response

monbrey commented 2 years ago

A really interesting concept and I'd love to see it implemented in some form or another, but it does concern me that this could fall victim to some similar issues as Structures.extend did which resulted in it's removal - giving users the ability to modify objects and behaviours that discord.js expects to exist in a certain way.

suneettipirneni commented 2 years ago

A really interesting concept and I'd love to see it implemented in some form or another, but it does concern me that this could fall victim to some similar issues as Structures.extend did which resulted in it's removal - giving users the ability to modify objects and behaviours that discord.js expects to exist in a certain way.

These are valid concerns. However, middleware is executed after djs has finished processing the raw ws event but before the the actual client event is invoked. This means that only users listening to events will receive changes not discord.js itself. So the only thing that changes is external behavior observed by the consumers of the library.

We can compare this to structures.extend which modifies both internal and external behaviors, which can lead to many unintended side effects.

monbrey commented 2 years ago

I might be misunderstanding something from your example then - the translation middleware appears to be extending the Interaction class and overriding the reply method. While I agree this doesn't modify discord.js internal behaviours, how does it ensure they are still executed?

Would this act as a form of "outgoing" middleware which must eventually call the base interaction.reply with some form of modified MessageOptions?

KokoNeotSide commented 2 years ago

I suggested this few months back. I'd like a middleware so I can edit a message payload before its sent.

Some use cases: translation popup embeds (something like dankmemer but this way it would be easier, before each embed is sent, aditional embed can be inserted into the payload with tips or support and stuff like that) some other validations and many more

nekomancer0 commented 5 months ago

We can do a thing simpler like Express with its .use method, that listens to event and can block, pass or update outgoing events ??