diff options
| author | MatthieuCoder <matthieu@matthieu-dev.xyz> | 2023-01-17 18:23:51 +0400 |
|---|---|---|
| committer | MatthieuCoder <matthieu@matthieu-dev.xyz> | 2023-01-17 18:23:51 +0400 |
| commit | b4bc9e35871ffefaddb8803ea02735cd4c465e45 (patch) | |
| tree | 12758366ea5bd4a1cab323f206a73a356317498e /src | |
| parent | b9251019bf2ad92542fde24b5d06059068455a1e (diff) | |
edit
Diffstat (limited to 'src')
| -rw-r--r-- | src/commands/index.ts | 4 | ||||
| -rw-r--r-- | src/commands/loveCalculator.ts | 134 | ||||
| -rw-r--r-- | src/commands/ping.ts | 95 | ||||
| -rw-r--r-- | src/index.ts | 40 | ||||
| -rw-r--r-- | src/register.ts | 18 | ||||
| -rw-r--r-- | src/sys/events/client.ts | 149 | ||||
| -rw-r--r-- | src/sys/events/index.ts | 1 | ||||
| -rw-r--r-- | src/sys/events/transport.ts | 185 | ||||
| -rw-r--r-- | src/sys/handler/builder.ts | 18 | ||||
| -rw-r--r-- | src/sys/handler/index.ts | 73 |
10 files changed, 0 insertions, 717 deletions
diff --git a/src/commands/index.ts b/src/commands/index.ts deleted file mode 100644 index 79ea03a..0000000 --- a/src/commands/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { loveCalculator } from './loveCalculator'; -import {ping} from './ping'; - -export const commands = [ping, loveCalculator]; diff --git a/src/commands/loveCalculator.ts b/src/commands/loveCalculator.ts deleted file mode 100644 index 3e42dab..0000000 --- a/src/commands/loveCalculator.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { - type APIApplicationCommandInteraction, - type APIInteractionResponse, - ApplicationCommandOptionType, - ApplicationCommandType, - InteractionResponseType, - APIUser, - MessageFlags, -} from "discord-api-types/v10"; -import { CommandBuilder, type HandlerFn } from "../sys/handler"; - -type Messages = { - low: []; - high: []; - average: []; - loveMessage: [APIUser, APIUser, number]; - sameUser: []; -}; - -type Locales = { - [K in keyof Messages]: Partial< - Record< - APIApplicationCommandInteraction["locale"] | "_", - (...args: Messages[K]) => string - > - >; -}; - -const locales: Locales = { - low: { - fr: () => "déteste", - _: () => "hates", - }, - average: { - fr: () => "est compatible avec", - _: () => "is compatible with", - }, - high: { - fr: () => "est fou/folle de", - _: () => "is in love with", - }, - - sameUser: { - fr: () => "Vous devez choisir deux utilisateurs distincts", - _: () => "You can't calculate love between the same user!", - }, - - loveMessage: { - fr: (user1: APIUser, user2: APIUser, percentage: number) => - `**${percentage}%**! ${user1.username}#${user1.discriminator} ${resolve( - percentage > 80 ? "high" : percentage <= 35 ? "low" : "average", - "fr" - )} ${user2.username}#${user2.discriminator}`, - _: (user1: APIUser, user2: APIUser, percentage: number) => - `**${percentage}%**! ${user1.username}#${user1.discriminator} ${resolve( - percentage > 80 ? "high" : percentage <= 35 ? "low" : "average", - "_" - )} ${user2.username}#${user2.discriminator}`, - }, -}; - -const resolve = <T extends keyof Messages>( - message: T, - locale: APIApplicationCommandInteraction["locale"] | "_", - ...args: Messages[T] -) => - ( - (locales[message][locale] ?? locales[message]._) as ( - ...args: Messages[T] - ) => string - )(...args); - -const handler: HandlerFn = async ({ - data, - locale, -}: APIApplicationCommandInteraction): Promise<APIInteractionResponse> => { - if ( - data.type === ApplicationCommandType.ChatInput && - data.options[0].name === "user1" && - data.options[0].type === ApplicationCommandOptionType.User && - data.options[1].name === "user2" && - data.options[1].type === ApplicationCommandOptionType.User - ) { - let user1 = data.resolved.users[data.options[0].value]; - let user2 = data.resolved.users[data.options[1].value]; - - if (user1.id === user2.id) { - return { - type: InteractionResponseType.ChannelMessageWithSource, - data: { - flags: MessageFlags.Ephemeral, - content: resolve("sameUser", locale), - }, - }; - } - - let percentage = Math.round(Math.random() * 100); - return { - type: InteractionResponseType.ChannelMessageWithSource, - data: { - content: resolve("loveMessage", locale, user1, user2, percentage), - }, - }; - } -}; - -export const loveCalculator = new CommandBuilder() - .setName("calculate-love") - .handler(handler) - .setDescription("Calcule the love compatibility between two users") - .addUserOption((option) => - option - .setName("user1") - .setDescription("The first user of the couple") - .setDescriptionLocalization("fr", "Le premier membre du couple") - .setNameLocalization("fr", "premier_utilisateur") - .setNameLocalization("en-US", "first_user") - .setRequired(true) - ) - .addUserOption((option) => - option - .setName("user2") - .setDescription("The second user of the couple") - .setDescriptionLocalization("fr", "Le deuxième membre du couple") - .setNameLocalization("fr", "second_utilisateur") - .setNameLocalization("en-US", "second_user") - .setRequired(true) - ) - .setDescriptionLocalizations({ - fr: "Calcule l'amour entre deux membres", - }) - .setNameLocalization("fr", "calculer-compatibilite") - .setDMPermission(false) - .build(); diff --git a/src/commands/ping.ts b/src/commands/ping.ts deleted file mode 100644 index f92485b..0000000 --- a/src/commands/ping.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { - type APIApplicationCommandInteraction, - type APIInteractionResponse, - ApplicationCommandOptionType, - ApplicationCommandType, - InteractionResponseType, -} from 'discord-api-types/v10'; -import {promise} from 'ping'; -import {CommandBuilder, type HandlerFn} from '../sys/handler'; - -type Messages = { - latencyMessage: [number]; - failureMessage: [string]; -}; - -type Locales = { - [K in keyof Messages]: Partial< - Record< - APIApplicationCommandInteraction['locale'] | '_', - (...args: Messages[K]) => string - > - >; -}; - -const locales: Locales = { - latencyMessage: { - fr: (latency: number) => - `**Temps de complétion du ping:** \`${latency}ms\``, - _: (latency: number) => `**Ping time:** \`${latency}ms\``, - }, - failureMessage: { - _: (error: string) => `**The host seems unreachable** \`\`\`${error}\`\`\``, - }, -}; - -const resolve = <T extends keyof Messages>( - message: T, - locale: APIApplicationCommandInteraction['locale'], - ...args: Messages[T] -) => - ( - (locales[message][locale] ?? locales[message]._) as ( - ...args: Messages[T] - ) => string - )(...args); - -const handler: HandlerFn = async ( - data: APIApplicationCommandInteraction, -): Promise<APIInteractionResponse> => { - if ( - data.data.type === ApplicationCommandType.ChatInput && - data.data.options[0].name === 'host' && - data.data.options[0].type === ApplicationCommandOptionType.String - ) { - const host = data.data.options[0].value; - const response = await promise.probe(host); - - if (response.alive) { - return { - type: InteractionResponseType.ChannelMessageWithSource, - data: { - content: resolve( - 'latencyMessage', - data.locale, - Number.parseInt(response.avg, 10), - ), - }, - }; - } - - const output = response.output; - return { - type: InteractionResponseType.ChannelMessageWithSource, - data: { - content: resolve('failureMessage', data.locale, output), - }, - }; - } -}; - -export const ping = new CommandBuilder() - .setName('ping') - .handler(handler) - .setDescription('Measure the bot network latency') - .addStringOption((option) => - option - .setName('host') - .setDescription('The host to test pings against') - .setRequired(true), - ) - .setDescriptionLocalizations({ - fr: 'Mesure la latence reseau du bot', - }) - .setDMPermission(false) - .build(); diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index af786e1..0000000 --- a/src/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -import 'source-map-support'; -import {Client} from './sys/events'; -import {buildHandler} from './sys/handler'; -import {commands} from './commands'; - -/** - * We instanciate our nova broker client. - */ -const emitter = new Client({ - transport: { - additionalEvents: [], - nats: { - servers: ['localhost:4222'], - }, - queue: 'nova-worker-common', - }, - rest: { - api: 'http://localhost:8090/api', - }, -}); - -// We register our slash command handler. -emitter.on('interactionCreate', buildHandler(commands)); - -// Simple message handler -emitter.onMessageCreate(async (message) => { - if (message.content === '~ping') { - await message.client.channels.createMessage(message.channel_id, { - content: `Bonjour! <@${message.author.id}>`, - }); - } else if (message.content === '~pid') { - - await message.client.channels.createMessage(message.channel_id, { - content: `Mon pid est ${process.pid}`, - }); - } -}); - -// We connect ourselves to the nova nats broker. -(async () => emitter.start())(); diff --git a/src/register.ts b/src/register.ts deleted file mode 100644 index 1758957..0000000 --- a/src/register.ts +++ /dev/null @@ -1,18 +0,0 @@ -import "source-map-support"; -import { REST } from "@discordjs/rest"; -import { commands } from "./commands"; -import { registerCommands } from "./sys/handler"; -import { RESTGetAPIUserResult, Routes } from "discord-api-types/v10"; - -const rest = new REST({ - version: "10", - api: "http://localhost:8090/api", -}).setToken("_"); - -/** - * We register the commands with discord - */ -(async () => { - let self = (await rest.get(Routes.user("@me"))) as RESTGetAPIUserResult; - registerCommands(commands, rest, self.id); -})(); diff --git a/src/sys/events/client.ts b/src/sys/events/client.ts deleted file mode 100644 index bad8203..0000000 --- a/src/sys/events/client.ts +++ /dev/null @@ -1,149 +0,0 @@ -import {EventEmitter} from 'node:stream'; -import {type CamelCase, type PascalCase} from 'type-fest'; -import {REST, type RESTOptions} from '@discordjs/rest'; -import { - type APIInteractionResponse, - type GatewayDispatchPayload, - type GatewayInteractionCreateDispatchData, -} from 'discord-api-types/v10'; -import type TypedEmitter from 'typed-emitter'; -import {API} from '@discordjs/core'; -import {Transport, type TransportOptions} from './transport'; - -/** - * Maps an event name (O['t']) and a Union O and extracts all the union members that have a matching O['t'] - * Example: - * type Variant1 = { t: 'type1', myProperty: 1 }; - * type Variant2 = { t: 'type2', anotherProperty: 2 }; - * type ExampleUnion = Variant1 | Variant2; - * - * let variant1: ExtractVariant<ExampleUnion, 'type1'>; // Type of variant1 is Variant1 - * let variant2: ExtractVariant<ExampleUnion, 'type2'>; // Type of variant2 is Variant2 - * - */ -type ExtractVariant<O extends {t: string}, U extends O['t']> = Extract< - O & {t: Exclude<O['t'], Exclude<O['t'], U>>}, - {t: U} ->; - -/** - * Add intrisics properties to the event, such as `client` and `rest` - */ -export type WithIntrisics<T> = T & {client: Client}; - -/** - * CamelCased event name - */ -export type EventName = keyof EventsHandlerArguments; -/** - * Reprends a handler function with one argument - */ -export type HandlerFunction<Arg extends unknown[]> = ( - ...args: Arg -) => PromiseLike<void>; - -export type EventTypes = { - [P in GatewayDispatchPayload['t']]: WithIntrisics< - ExtractVariant<GatewayDispatchPayload, P>['d'] - >; -}; - -/** - * Maps all events from GatewayDispatchPayload['t'] (GatewayDispatchEvents) and maps them to a camelcase event name - * Also reteives the type of the event using ExtractEvent - */ -export type EventsHandlerArguments = { - [P in keyof EventTypes as `${CamelCase<P>}`]: HandlerFunction< - [EventTypes[P]] - >; -} & { - interactionCreate: HandlerFunction< - [ - WithIntrisics<GatewayInteractionCreateDispatchData>, - (interactionCreate: APIInteractionResponse) => void, - ] - >; -}; - -/** - * Defines all the 'on...' functions on the client - * This is implemented by a Proxy - */ -export type EventsFunctions = { - [P in keyof EventsHandlerArguments as P extends string - ? `on${PascalCase<P>}` - : never]: (fn: EventsHandlerArguments[P]) => Client; -}; - -/** - * Defines all the methods known to be implemented - */ -type ClientFunctions = Record<string, unknown> & - EventsFunctions & - TypedEmitter<EventsHandlerArguments> & - API; - -/** - * The real extended class is an EventEmitter. - */ -// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -const undefinedClient: new () => ClientFunctions = EventEmitter as any; - -/** - * Nova.js client - * - * Used to interact with nova, emits events from nova - * Example: - * client.on('messageCreate', (message) => { console.log('Message received', message.content) }); - * client.on('interactionCreate', (message) => { }); - */ -export class Client extends undefinedClient { - public readonly rest: REST; - - private readonly transport: Transport; - private readonly api: API; - - constructor(options: { - rest?: Partial<RESTOptions>; - transport: TransportOptions; - }) { - super(); - this.rest = new REST(options.rest).setToken('_'); - this.api = new API(this.rest); - - // Using a proxy to provide the 'on...' functionality - let self = new Proxy(this, { - get(self, symbol: keyof typeof Client) { - const name = symbol.toString(); - if (name.startsWith('on') && name.length > 2) { - // Get the event name - const eventName = [name[2].toLowerCase(), name.slice(3)].join( - '', - ) as EventName; - return (fn: EventsHandlerArguments[typeof eventName]) => - self.on(eventName, fn); - } - - if (self.api[symbol] && !self[symbol as string]) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return self.api[symbol]; - } - - return self[symbol as string]; - }, - }); - - this.transport = new Transport(self, options.transport); - - // This is safe because this event is emitted by the EventEmitter itself. - this.on('newListener' as any, async (event: EventName) => { - await this.transport.subscribe(event); - }); - - return self; - } - - public async start() { - return this.transport.start(); - } -} diff --git a/src/sys/events/index.ts b/src/sys/events/index.ts deleted file mode 100644 index 4f1cce4..0000000 --- a/src/sys/events/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './client'; diff --git a/src/sys/events/transport.ts b/src/sys/events/transport.ts deleted file mode 100644 index c3c9f7a..0000000 --- a/src/sys/events/transport.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { Buffer } from "node:buffer"; -import { - connect, - type ConnectionOptions, - type NatsConnection, - type Subscription, -} from "nats"; -import globRegex from "glob-regex"; -import { - type APIInteractionResponse, - InteractionResponseType, - type APIInteractionResponseCallbackData, - type GatewayDispatchPayload, - Routes, -} from "discord-api-types/v10"; -import { type CamelCase } from "type-fest"; -import { type Client, type EventName, type EventsHandlerArguments } from "."; - -/** - * Options for the nats transport layer - */ -export type TransportOptions = { - additionalEvents?: Array<keyof EventsHandlerArguments>; - nats?: ConnectionOptions; - queue: string; -}; - -/** - * Transport implements all the communication to Nova using Nats - */ -export class Transport { - // Nats connection - private nats: NatsConnection | undefined = null; - // Current subscriptions - private readonly subscriptions = new Map<string, Subscription>(); - // Current subscribed events - private readonly events = new Set<EventName>(); - - // Creats a new Transport instance. - constructor( - private readonly emitter: Client, - private readonly config: Partial<TransportOptions> - ) {} - - /** - * Starts a new nats client. - */ - public async start() { - this.nats = await connect(this.config?.nats); - - await Promise.all( - [...this.events].map(async (eventName) => this.subscribe(eventName)) - ); - - if (this.config.additionalEvents) { - await Promise.all( - this.config.additionalEvents.map(async (eventName) => - this.subscribe(eventName) - ) - ); - } - } - - /** - * Subscribe to a new topic - * @param event Event to subscribe to - * @returns - */ - public async subscribe(event: EventName) { - // If nats is not connected, we simply request to subscribe to it at startup - if (!this.nats) { - console.log("Requesting event " + event); - this.events.add(event); - return; - } - - // Since the event names used by this library are camelCase'd we need to - // re-transform it to the UPPER_CASE used by nova. - const dashed = event.replace(/[A-Z]/g, (m) => "_" + m.toLowerCase()); - // Construct the topic name used by nova. - // This **is going to change** as we implement the caching component. - const topic = `nova.cache.dispatch.${dashed.toUpperCase()}`; - - // To avoid having multiple subscriptions covering this event - // we check if each of our subscriptions covers this scope. - const isAlreadyPresent = [...this.subscriptions.keys()].reduce( - (previous, current) => { - if (previous) { - return previous; - } - - const regex = globRegex(current); - - return regex.test(topic); - }, - false - ); - - // We abord the subscriptions if it's already covered. - if (isAlreadyPresent) { - console.warn("nats subscription already covered."); - return; - } - - // We remove all the subscriptions that are covered by out current subsciptions. - const regex = globRegex(topic); - for (const key of this.subscriptions.keys()) { - if (regex.test(key)) { - const subsciption = this.subscriptions.get(key); - if (!subsciption) { - continue; - } - - console.log(`unsubscribing from ${key}`); - subsciption.unsubscribe(); - } - } - - void this._subscriptionTask(topic); - } - - // Task that monitors the subscription - // It also listens for a subscription end. - private async _subscriptionTask(topic: string) { - if (!this.nats) { - throw new Error("nats connection is not started"); - } - - console.log(`subscribing to ${topic}`); - // Create the nats subscription - const subscription = this.nats.subscribe(topic, { - queue: this.config.queue || "nova_consumer", - }); - this.subscriptions.set(topic, subscription); - // Handle each event in the subscription stream. - for await (const publish of subscription) { - try { - // Decode the payload - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const event: GatewayDispatchPayload = JSON.parse( - Buffer.from(publish.data).toString("utf8") - ); - // Transform the event name to a camclCased name - const camelCasedName = event.t - .toLowerCase() - .replace(/_([a-z])/g, (g) => g[1].toUpperCase()) as CamelCase< - typeof event.t - >; - - // Since an interaction need a reponse, - // we need to handle the case where nova is not configured - // with a webhook endpoint, hence we need to use a post request - // against webhook execute endpoint with the interaction data. - if (event.t === "INTERACTION_CREATE") { - const interaction = event.d; - const respond = async (respond: APIInteractionResponse) => { - if (publish.reply) { - publish.respond(Buffer.from(JSON.stringify(respond), "utf8")); - } else { - await this.emitter.rest.post( - Routes.interactionCallback(interaction.id, interaction.token), - { - body: respond, - } - ); - } - }; - - // Emit the - this.emitter.emit( - camelCasedName, - { ...event.d, client: this.emitter }, - respond - ); - } else { - // Typescript refuses to infer this, whyyy - this.emitter.emit(camelCasedName, { - ...event.d, - client: this.emitter, - } as any); - } - } catch {} - } - } -} diff --git a/src/sys/handler/builder.ts b/src/sys/handler/builder.ts deleted file mode 100644 index 71732a8..0000000 --- a/src/sys/handler/builder.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {SlashCommandBuilder} from '@discordjs/builders'; -import {type Command, type HandlerFn} from '.'; - -/** - * Simple wrapper around the SlashCommandBuilder provided by Discord.js - */ -export class CommandBuilder extends SlashCommandBuilder { - private _handler: HandlerFn; - - handler(handler: HandlerFn): this { - this._handler = handler; - return this; - } - - build(): Command { - return {json: this.toJSON(), handler: this._handler}; - } -} diff --git a/src/sys/handler/index.ts b/src/sys/handler/index.ts deleted file mode 100644 index 1512ab7..0000000 --- a/src/sys/handler/index.ts +++ /dev/null @@ -1,73 +0,0 @@ -import {type REST} from '@discordjs/rest'; -import { - type APIApplicationCommandInteraction, - type APIInteraction, - InteractionType, - type RESTPostAPIApplicationCommandsJSONBody, - type RESTPostAPIChatInputApplicationCommandsJSONBody, - Routes, - type APIInteractionResponse, -} from 'discord-api-types/v10'; - -export * from './builder'; - -export type PromiseLike<T> = T | Promise<T>; -/** - * A simple function that executes a slash command. - */ -export type HandlerFn = ( - data: APIApplicationCommandInteraction, -) => PromiseLike<APIInteractionResponse>; - -export type Command = { - json: RESTPostAPIChatInputApplicationCommandsJSONBody; - handler: HandlerFn; -}; - -/** - * Register all the commands to discord - * @param commands List of commands to register - * @param rest Rest api instance - * @param applicationId Current application id - */ -export const registerCommands = async ( - commands: Iterable<Command>, - rest: REST, - appId: string, -) => { - await rest.put(Routes.applicationCommands(appId), { - body: [...commands].map( - (x) => x.json, - ) as RESTPostAPIApplicationCommandsJSONBody[], - }); -}; - -/** - * Creates a new handler to handle the slash commands. - * @param commands List of commands to handle - * @returns Handler function - */ -export const buildHandler = (commands: Iterable<Command>) => { - const internal = new Map<string, Command>(); - for (const command of commands) { - internal.set(command.json.name, command); - } - - return async ( - event: APIInteraction, - reply?: (data: APIInteractionResponse) => void, - ) => { - console.log('executing:', event.data); - if (event.type === InteractionType.ApplicationCommand) { - console.log('executing:', event.data); - const command = internal.get(event.data.name); - - if (command) { - const data = await command.handler(event); - console.log('sending reply', data); - - reply(data); - } - } - }; -}; |
