Trabajar con TypeScript, inyección de dependencia y bots de Discord

Publicado: 2022-03-11

Los tipos y el código comprobable son dos de las formas más efectivas de evitar errores, especialmente cuando el código cambia con el tiempo. Podemos aplicar estas dos técnicas al desarrollo de JavaScript aprovechando TypeScript y el patrón de diseño de inyección de dependencia (DI), respectivamente.

En este tutorial de TypeScript, no cubriremos los conceptos básicos de TypeScript directamente, excepto la compilación. En su lugar, simplemente demostraremos las mejores prácticas de TypeScript a medida que explicamos cómo crear un bot de Discord desde cero, conectar pruebas y DI, y crear un servicio de muestra. Estaremos usando:

  • Nodo.js
  • Mecanografiado
  • Discord.js, un contenedor para la API de Discord
  • InversifyJS, un marco de inyección de dependencia
  • Bibliotecas de prueba: Mocha, Chai y ts-mockito
  • Bonificación: Mongoose y MongoDB, para escribir una prueba de integración

Configuración de su proyecto Node.js

Primero, creemos un nuevo directorio llamado typescript-bot . Luego, ingrésalo y crea un nuevo proyecto Node.js ejecutando:

 npm init

Nota: también podría usar yarn para eso, pero limitémonos a npm por brevedad.

Esto abrirá un asistente interactivo, que configurará el archivo package.json . Puede presionar Entrar de forma segura para todas las preguntas (o proporcionar alguna información si lo desea). Luego, instalemos nuestras dependencias y dependencias de desarrollo (aquellas que solo se necesitan para las pruebas).

 npm i --save typescript discord.js inversify dotenv @types/node reflect-metadata npm i --save-dev chai mocha ts-mockito ts-node @types/chai @types/mocha

Luego, reemplace la sección de "scripts" generados en package.json con:

 "scripts": { "start": "node src/index.js", "watch": "tsc -p tsconfig.json -w", "test": "mocha -r ts-node/register \"tests/**/*.spec.ts\"" },

Las comillas dobles alrededor de tests/**/*.spec.ts son necesarias para encontrar archivos recursivamente. (Nota: la sintaxis puede variar para los desarrolladores que usan Windows).

La secuencia de comandos de start se usará para iniciar el bot, la secuencia de comandos de watch para compilar el código de TypeScript y la test para ejecutar las pruebas.

Ahora, nuestro archivo package.json debería verse así:

 { "name": "typescript-bot", "version": "1.0.0", "description": "", "main": "index.js", "dependencies": { "@types/node": "^11.9.4", "discord.js": "^11.4.2", "dotenv": "^6.2.0", "inversify": "^5.0.1", "reflect-metadata": "^0.1.13", "typescript": "^3.3.3" }, "devDependencies": { "@types/chai": "^4.1.7", "@types/mocha": "^5.2.6", "chai": "^4.2.0", "mocha": "^5.2.0", "ts-mockito": "^2.3.1", "ts-node": "^8.0.3" }, "scripts": { "start": "node src/index.js", "watch": "tsc -p tsconfig.json -w", "test": "mocha -r ts-node/register \"tests/**/*.spec.ts\"" }, "author": "", "license": "ISC" }

Creación de una nueva aplicación en el panel de aplicaciones de Discord

Para interactuar con la API de Discord, necesitamos un token. Para generar dicho token, debemos registrar una aplicación en el panel de desarrolladores de Discord. Para hacerlo, debe crear una cuenta de Discord e ir a https://discordapp.com/developers/applications/. Luego, haga clic en el botón Nueva aplicación :

Botón "Nueva aplicación" de Discord.

Elija un nombre y haga clic en Crear . Luego, haga clic en BotAgregar Bot y listo. Agreguemos el bot a un servidor. Pero no cierres esta página todavía, necesitaremos copiar un token pronto.

Agregue su Discord Bot a su servidor

Para probar nuestro bot, necesitamos un servidor Discord. Puede utilizar un servidor existente o crear uno nuevo. Para hacer esto, copie el CLIENT_ID del bot, que se encuentra en la pestaña Información general, y utilícelo como parte de esta URL de autorización especial:

https://discordapp.com/oauth2/authorize?client_id=<CLIENT_ID>&scope=bot

Cuando presiona esta URL en un navegador, aparece un formulario donde puede elegir el servidor donde se debe agregar el bot.

Mensaje de bienvenida estándar de Discord en respuesta a que nuestro bot se una al servidor.

Después de agregar el bot a su servidor, debería ver un mensaje como el anterior.

Creación del archivo .env

Necesitamos alguna forma de guardar el token en nuestra aplicación. Para hacer eso, vamos a usar el paquete dotenv . Primero, obtenga el token del panel de aplicaciones de Discord ( BotHaga clic para revelar el token ):

El enlace "Haga clic para revelar el token" en la sección Bot de Discord.

Ahora, cree un archivo .env , luego copie y pegue el token aquí:

 TOKEN=paste.the.token.here

Si usa Git, este archivo debe colocarse en .gitignore para que el token no se vea comprometido. Además, cree un archivo .env.example , para que se sepa que TOKEN necesita definirse:

 TOKEN=

Compilando mecanografiado

Para compilar TypeScript, puede usar el comando npm run watch . Alternativamente, si usa PHPStorm (u otro IDE), simplemente use su observador de archivos desde su complemento TypeScript y deje que su IDE maneje la compilación. Probemos nuestra configuración creando un archivo src/index.ts con el contenido:

 console.log('Hello')

Además, creemos un archivo tsconfig.json como el que se muestra a continuación. InversifyJS requiere experimentalDecorators , emitDecoratorMetadata , es6 y reflect reflect-metadata :

 { "compilerOptions": { "module": "commonjs", "moduleResolution": "node", "target": "es2016", "lib": [ "es6", "dom" ], "sourceMap": true, "types": [ // add node as an option "node", "reflect-metadata" ], "typeRoots": [ // add path to @types "node_modules/@types" ], "experimentalDecorators": true, "emitDecoratorMetadata": true, "resolveJsonModule": true }, "exclude": [ "node_modules" ] }

Si el observador de archivos funciona correctamente, debería generar un archivo src/index.js y ejecutar npm start debería dar como resultado:

 > node src/index.js Hello

Creación de una clase de bot

Ahora, finalmente comencemos a usar la característica más útil de TypeScript: tipos. Continúe y cree el siguiente archivo src/bot.ts :

 import {Client, Message} from "discord.js"; export class Bot { public listen(): Promise<string> { let client = new Client(); client.on('message', (message: Message) => {}); return client.login('token should be here'); } }

Ahora, podemos ver lo que necesitamos aquí: ¡una ficha! ¿Vamos a simplemente copiarlo y pegarlo aquí o cargar el valor directamente desde el entorno?

Ninguno de los dos. En su lugar, escribamos un código más mantenible, extensible y comprobable inyectando el token utilizando nuestro marco de inyección de dependencia de elección, InversifyJS.

Además, podemos ver que la dependencia del Client está codificada. Vamos a inyectar esto también.

Configuración del contenedor de inyección de dependencia

Un contenedor de inyección de dependencia es un objeto que sabe cómo instanciar otros objetos. Por lo general, definimos dependencias para cada clase y el contenedor DI se encarga de resolverlas.

InversifyJS recomienda colocar las dependencias en un archivo inversify.config.ts , así que sigamos adelante y agreguemos nuestro contenedor DI allí:

 import "reflect-metadata"; import {Container} from "inversify"; import {TYPES} from "./types"; import {Bot} from "./bot"; import {Client} from "discord.js"; let container = new Container(); container.bind<Bot>(TYPES.Bot).to(Bot).inSingletonScope(); container.bind<Client>(TYPES.Client).toConstantValue(new Client()); container.bind<string>(TYPES.Token).toConstantValue(process.env.TOKEN); export default container;

Además, los documentos de InversifyJS recomiendan crear un archivo types.ts y enumerar cada tipo que vamos a usar, junto con un Symbol relacionado. Esto es bastante inconveniente, pero asegura que no haya colisiones de nombres a medida que crece nuestra aplicación. Cada Symbol es un identificador único, incluso cuando su parámetro de descripción es el mismo (el parámetro es solo para fines de depuración).

 export const TYPES = { Bot: Symbol("Bot"), Client: Symbol("Client"), Token: Symbol("Token"), };

Sin usar Symbol s, así es como se ve cuando ocurre una colisión de nombres:

 Error: Ambiguous match found for serviceIdentifier: MessageResponder Registered bindings: MessageResponder MessageResponder

En este punto, es aún más inconveniente decidir qué MessageResponder se debe usar, especialmente si nuestro contenedor DI crece. El uso de Symbol s se ocupa de eso, y no hemos encontrado literales de cadena extraños en el caso de tener dos clases con el mismo nombre.

Uso del contenedor en la aplicación Discord Bot

Ahora, modifiquemos nuestra clase Bot para usar el contenedor. Necesitamos agregar @injectable y @inject() para hacerlo. Aquí está la nueva clase Bot :

 import {Client, Message} from "discord.js"; import {inject, injectable} from "inversify"; import {TYPES} from "./types"; import {MessageResponder} from "./services/message-responder"; @injectable() export class Bot { private client: Client; private readonly token: string; constructor( @inject(TYPES.Client) client: Client, @inject(TYPES.Token) token: string ) { this.client = client; this.token = token; } public listen(): Promise < string > { this.client.on('message', (message: Message) => { console.log("Message received! Contents: ", message.content); }); return this.client.login(this.token); } }

Finalmente, instanciamos nuestro bot en el archivo index.ts :

 require('dotenv').config(); // Recommended way of loading dotenv import container from "./inversify.config"; import {TYPES} from "./types"; import {Bot} from "./bot"; let bot = container.get<Bot>(TYPES.Bot); bot.listen().then(() => { console.log('Logged in!') }).catch((error) => { console.log('Oh no! ', error) });

Ahora, inicie el bot y agréguelo a su servidor. Luego, si escribe un mensaje en el canal del servidor, debería aparecer en los registros en la línea de comando así:

 > node src/index.js Logged in! Message received! Contents: Test

Finalmente, tenemos las bases configuradas: tipos de TypeScript y un contenedor de inyección de dependencia dentro de nuestro bot.

Implementación de la lógica de negocios

Vayamos directamente al núcleo de lo que trata este artículo: crear una base de código comprobable. En resumen, nuestro código debe implementar las mejores prácticas (como SOLID), no ocultar dependencias, no usar métodos estáticos.

Además, no debería presentar efectos secundarios cuando se ejecuta y ser fácilmente burlable.

En aras de la simplicidad, nuestro bot solo hará una cosa: buscará los mensajes entrantes y, si uno contiene la palabra "ping", usaremos uno de los comandos de bot de Discord disponibles para que el bot responda con "¡pong! ” a ese usuario.

Para mostrar cómo inyectar objetos personalizados en el objeto Bot y probarlos unitariamente, crearemos dos clases: PingFinder y MessageResponder . Inyectaremos MessageResponder en la clase Bot y PingFinder en MessageResponder .

Aquí está el archivo src/services/ping-finder.ts :

 import {injectable} from "inversify"; @injectable() export class PingFinder { private regexp = 'ping'; public isPing(stringToSearch: string): boolean { return stringToSearch.search(this.regexp) >= 0; } }

Luego inyectamos esa clase en el archivo src/services/message-responder.ts :

 import {Message} from "discord.js"; import {PingFinder} from "./ping-finder"; import {inject, injectable} from "inversify"; import {TYPES} from "../types"; @injectable() export class MessageResponder { private pingFinder: PingFinder; constructor( @inject(TYPES.PingFinder) pingFinder: PingFinder ) { this.pingFinder = pingFinder; } handle(message: Message): Promise<Message | Message[]> { if (this.pingFinder.isPing(message.content)) { return message.reply('pong!'); } return Promise.reject(); } }

Por último, aquí hay una clase Bot modificada, que usa la clase MessageResponder :

 import {Client, Message} from "discord.js"; import {inject, injectable} from "inversify"; import {TYPES} from "./types"; import {MessageResponder} from "./services/message-responder"; @injectable() export class Bot { private client: Client; private readonly token: string; private messageResponder: MessageResponder; constructor( @inject(TYPES.Client) client: Client, @inject(TYPES.Token) token: string, @inject(TYPES.MessageResponder) messageResponder: MessageResponder) { this.client = client; this.token = token; this.messageResponder = messageResponder; } public listen(): Promise<string> { this.client.on('message', (message: Message) => { if (message.author.bot) { console.log('Ignoring bot message!') return; } console.log("Message received! Contents: ", message.content); this.messageResponder.handle(message).then(() => { console.log("Response sent!"); }).catch(() => { console.log("Response not sent.") }) }); return this.client.login(this.token); } }

En ese estado, la aplicación no podrá ejecutarse porque no hay definiciones para las clases MessageResponder y PingFinder . Agreguemos lo siguiente al archivo inversify.config.ts :

 container.bind<MessageResponder>(TYPES.MessageResponder).to(MessageResponder).inSingletonScope(); container.bind<PingFinder>(TYPES.PingFinder).to(PingFinder).inSingletonScope();

Además, vamos a agregar símbolos de tipo a types.ts :

 MessageResponder: Symbol("MessageResponder"), PingFinder: Symbol("PingFinder"),

Ahora, después de reiniciar nuestra aplicación, el bot debería responder a todos los mensajes que contengan "ping":

El bot responde a un mensaje que contiene la palabra "ping".

Y así es como se ve en los registros:

 > node src/index.js Logged in! Message received! Contents: some message Response not sent. Message received! Contents: message with ping Ignoring bot message! Response sent!

Creación de pruebas unitarias

Ahora que hemos inyectado correctamente las dependencias, escribir pruebas unitarias es fácil. Vamos a usar Chai y ts-mockito para eso; sin embargo, hay muchos otros corredores de prueba y bibliotecas de simulación que podría usar.

La sintaxis burlona en ts-mockito es bastante detallada, pero también fácil de entender. Aquí se explica cómo configurar el servicio MessageResponder e inyectar el simulacro de PingFinder en él:

 let mockedPingFinderClass = mock(PingFinder); let mockedPingFinderInstance = instance(mockedPingFinderClass); let service = new MessageResponder(mockedPingFinderInstance);

Ahora que hemos configurado los simulacros, podemos definir cuál debería ser el resultado de las llamadas isPing() y verificar las llamadas de reply() . El punto es que en las pruebas unitarias, definimos el resultado de la llamada isPing() : true o false . No importa cuál sea el contenido del mensaje, por lo que en las pruebas solo usamos "Non-empty string" .

 when(mockedPingFinderClass.isPing("Non-empty string")).thenReturn(true); await service.handle(mockedMessageInstance) verify(mockedMessageClass.reply('pong!')).once();

Así es como se vería todo el conjunto de pruebas:

 import "reflect-metadata"; import 'mocha'; import {expect} from 'chai'; import {PingFinder} from "../../../src/services/ping-finder"; import {MessageResponder} from "../../../src/services/message-responder"; import {instance, mock, verify, when} from "ts-mockito"; import {Message} from "discord.js"; describe('MessageResponder', () => { let mockedPingFinderClass: PingFinder; let mockedPingFinderInstance: PingFinder; let mockedMessageClass: Message; let mockedMessageInstance: Message; let service: MessageResponder; beforeEach(() => { mockedPingFinderClass = mock(PingFinder); mockedPingFinderInstance = instance(mockedPingFinderClass); mockedMessageClass = mock(Message); mockedMessageInstance = instance(mockedMessageClass); setMessageContents(); service = new MessageResponder(mockedPingFinderInstance); }) it('should reply', async () => { whenIsPingThenReturn(true); await service.handle(mockedMessageInstance); verify(mockedMessageClass.reply('pong!')).once(); }) it('should not reply', async () => { whenIsPingThenReturn(false); await service.handle(mockedMessageInstance).then(() => { // Successful promise is unexpected, so we fail the test expect.fail('Unexpected promise'); }).catch(() => { // Rejected promise is expected, so nothing happens here }); verify(mockedMessageClass.reply('pong!')).never(); }) function setMessageContents() { mockedMessageInstance.content = "Non-empty string"; } function whenIsPingThenReturn(result: boolean) { when(mockedPingFinderClass.isPing("Non-empty string")).thenReturn(result); } });

Las pruebas para PingFinder son bastante triviales ya que no hay dependencias de las que burlarse. Aquí hay un caso de prueba de ejemplo:

 describe('PingFinder', () => { let service: PingFinder; beforeEach(() => { service = new PingFinder(); }) it('should find "ping" in the string', () => { expect(service.isPing("ping")).to.be.true }) });

Creación de pruebas de integración

Además de las pruebas unitarias, también podemos escribir pruebas de integración. La principal diferencia es que las dependencias en esas pruebas no se burlan. Sin embargo, hay algunas dependencias que no deben probarse, como las conexiones API externas. En ese caso, podemos crear simulacros y rebind a vincularlos al contenedor, de modo que el simulacro se inyecte en su lugar. Aquí hay un ejemplo de cómo hacer eso:

 import container from "../../inversify.config"; import {TYPES} from "../../src/types"; // ... describe('Bot', () => { let discordMock: Client; let discordInstance: Client; let bot: Bot; beforeEach(() => { discordMock = mock(Client); discordInstance = instance(discordMock); container.rebind<Client>(TYPES.Client) .toConstantValue(discordInstance); bot = container.get<Bot>(TYPES.Bot); }); // Test cases here });

Esto nos lleva al final de nuestro tutorial de bot de Discord. ¡Felicitaciones, lo creaste limpiamente, con TypeScript y DI en su lugar desde el principio! Este ejemplo de inyección de dependencia de TypeScript es un patrón que puede agregar a su repertorio para usar con cualquier proyecto.

TypeScript e inyección de dependencias: no solo para el desarrollo de bots de Discord

Traer el mundo orientado a objetos de TypeScript a JavaScript es una gran mejora, ya sea que estemos trabajando en código front-end o back-end. El solo uso de tipos nos permite evitar muchos errores. Tener la inyección de dependencia en TypeScript impulsa aún más las mejores prácticas orientadas a objetos en el desarrollo basado en JavaScript.

Por supuesto, debido a las limitaciones del lenguaje, nunca será tan fácil y natural como en los lenguajes tipificados estáticamente. Pero una cosa es segura: TypeScript, las pruebas unitarias y la inyección de dependencia nos permiten escribir un código más legible, débilmente acoplado y mantenible, sin importar qué tipo de aplicación estemos desarrollando.

Relacionado: Cree un chatbot de WhatsApp, no una aplicación