diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/commands/index.ts | 3 | ||||
| -rw-r--r-- | src/commands/ping.ts | 86 | ||||
| -rw-r--r-- | src/events/client.ts | 33 | ||||
| -rw-r--r-- | src/events/event-emitter.ts | 51 | ||||
| -rw-r--r-- | src/events/index.ts | 1 | ||||
| -rw-r--r-- | src/events/transport.ts | 111 | ||||
| -rw-r--r-- | src/handler/builder.ts | 19 | ||||
| -rw-r--r-- | src/handler/index.ts | 57 | ||||
| -rw-r--r-- | src/index.ts | 52 | ||||
| -rw-r--r-- | src/register.ts | 5 | ||||
| -rw-r--r-- | src/rest.ts | 8 |
11 files changed, 426 insertions, 0 deletions
diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..366e781 --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,3 @@ +import { ping } from "./ping"; + +export const commands = [ping]; diff --git a/src/commands/ping.ts b/src/commands/ping.ts new file mode 100644 index 0000000..9bf6bc0 --- /dev/null +++ b/src/commands/ping.ts @@ -0,0 +1,86 @@ +import { + APIApplicationCommandInteraction, + APIInteractionResponse, + ApplicationCommandOptionType, + ApplicationCommandType, + InteractionResponseType, +} from "discord-api-types/v10"; +import { CommandBuilder, HandlerFn } from "../handler"; +import { promise } from "ping"; + +type Messages = { + latency_message: [number]; + failure_message: [string]; +}; + +type Locales = { + [K in keyof Messages]: Partial< + Record< + APIApplicationCommandInteraction["locale"] & "_", + (...args: Messages[K]) => string + > + >; +}; + +const locales: Locales = { + latency_message: { + fr: (latency: number) => + `**Temps de complétion du ping:** \`${latency}ms\``, + _: (latency: number) => `**Ping time:** \`${latency}ms\``, + }, + failure_message: { + _: (error: string) => `**The host seems unreachable** \`\`\`${error}\`\`\``, + }, +}; + +const resolve = ( + msg: keyof Messages, + locale: APIApplicationCommandInteraction["locale"], + ...args: Messages[typeof msg] +) => (locales[msg][locale] ?? locales[msg]["_"])(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 + ) { + let host = data.data.options[0].value; + let response = await promise.probe(host); + + if (response.alive) { + return { + type: InteractionResponseType.ChannelMessageWithSource, + data: { + content: resolve("latency_message", data.locale, response.avg), + }, + }; + } else { + let output = response.output; + return { + type: InteractionResponseType.ChannelMessageWithSource, + data: { + content: resolve("failure_message", 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/events/client.ts b/src/events/client.ts new file mode 100644 index 0000000..b4045a0 --- /dev/null +++ b/src/events/client.ts @@ -0,0 +1,33 @@ +import { GatewayDispatchPayload } from "discord-api-types/v10"; +import { BaseEventEmitter, HandlerFunction } from "./event-emitter"; +import { Transport, TransportOptions } from "./transport"; +import { CamelCase } from "type-fest"; + +type ExtractEvent<O extends GatewayDispatchPayload, U extends O["t"]> = Extract< + O & { t: Exclude<O["t"], Exclude<O["t"], U>> }, + { t: U } +>; + +export type Events = { + [P in GatewayDispatchPayload["t"] as `${CamelCase<P>}` | P]: [ + ExtractEvent<GatewayDispatchPayload, P>["d"] + ]; +}; + +export class EventClient extends BaseEventEmitter { + public transport: Transport; + // constructs + constructor() { + super(); + this.transport = new Transport(this); + } + + public async start(options: TransportOptions) { + await this.transport.start(options); + } + + on<K extends keyof Events>(name: K, fn: HandlerFunction<Events[K]>): this { + this.transport.subscribe(name); + return super.on(name, fn); + } +} diff --git a/src/events/event-emitter.ts b/src/events/event-emitter.ts new file mode 100644 index 0000000..8a106c9 --- /dev/null +++ b/src/events/event-emitter.ts @@ -0,0 +1,51 @@ +import { EventEmitter } from "events"; +import { PascalCase } from "type-fest"; +import { Events } from "."; + +export type HandlerFunction<Args extends unknown[]> = ( + ...args: [...Args, ...[resolve?: (data: object) => void]] +) => unknown | Promise<unknown>; + +export type EventsFunctions = { + [P in keyof Events as P extends string ? `on${PascalCase<P>}` : never]: ( + fn: HandlerFunction<Events[P]> + ) => BaseEventEmitter; +}; + +// Typings for the EventClient +export interface BaseEventEmitter extends EventEmitter { + addListener<K extends keyof Events>( + name: K, + fn: HandlerFunction<Events[K]> + ): this; + + on<K extends keyof Events>(name: K, fn: HandlerFunction<Events[K]>): this; + + once<K extends keyof Events>(name: K, fn: HandlerFunction<Events[K]>): this; + + off<K extends keyof Events>(name: K, fn: HandlerFunction<Events[K]>): this; + + prependListener<K extends keyof Events>( + name: K, + fn: HandlerFunction<Events[K]> + ): this; + + prependOnceListener<K extends keyof Events>( + name: K, + fn: HandlerFunction<Events[K]> + ): this; + + removeAllListeners(eventName: keyof Events | undefined): this; + removeListener(eventName: keyof Events): this; + + emit<T extends keyof Events>(name: T, ...args: Events[T]): boolean; + listenerCount(event: keyof Events): number; + listeners<T extends keyof Events>(event: T): HandlerFunction<Events[T]>[]; + rawListeners: this["listeners"]; +} + +export class BaseEventEmitter extends EventEmitter implements BaseEventEmitter { + constructor() { + super(); + } +} diff --git a/src/events/index.ts b/src/events/index.ts new file mode 100644 index 0000000..5ec7692 --- /dev/null +++ b/src/events/index.ts @@ -0,0 +1 @@ +export * from "./client"; diff --git a/src/events/transport.ts b/src/events/transport.ts new file mode 100644 index 0000000..4f2abd6 --- /dev/null +++ b/src/events/transport.ts @@ -0,0 +1,111 @@ +import { connect, ConnectionOptions, NatsConnection } from "nats"; +import { EventClient, Events } from "."; +import globRegex from "glob-regex"; + +export type TransportOptions = { + additionalEvents?: (keyof Events)[]; + nats?: ConnectionOptions; + queue: string; +}; +export class Transport { + private nats: NatsConnection | null = null; + private subscription: Map<string, Function> = new Map(); + private queue?: string; + private events: Set<string> = new Set(); + + constructor(private emitter: EventClient) {} + + public async start(options: TransportOptions) { + this.nats = await connect(options?.nats); + this.queue = options.queue; + if (options.additionalEvents) { + options.additionalEvents.forEach((a) => this.events.add(a)); + } + + let initial_events = [...this.events]; + + for (let subscription of initial_events) { + await this.subscribe(subscription); + } + } + + public async subscribe(event: string) { + if (!this.nats) { + console.log("Requesting event " + event); + this.events.add(event); + return; + } + let dashed = event.replace(/[A-Z]/g, (m) => "_" + m.toLowerCase()); + event = `nova.cache.dispatch.${dashed.toUpperCase()}`; + + let isAlreadyPresent = [...this.subscription.keys()].reduce( + (previous, current) => { + if (previous) return previous; + let regex = globRegex(current); + + return regex.test(event); + }, + false + ); + + if (isAlreadyPresent) { + console.warn("nats subscription already covered."); + return; + } + + let regex = globRegex(event); + [...this.subscription.keys()].map((key) => { + if (regex.test(key)) { + let v = this.subscription.get(key); + if (!v) { + return; + } + + console.log(`unsubscribing from ${key}`); + v(); + } + }); + + this._subTask(event, this.queue || "default_queue"); + } + + private async _subTask(event: string, queue: string) { + if (!this.nats) { + throw new Error("nats transporter is not started."); + } + + console.log(`subscribing to ${event}`); + let resolve: Function = () => {}; + let task = new Promise((r) => { + resolve = r; + }); + let sub = this.nats.subscribe(event, { queue: "" }); + + const fn = async () => { + for await (let data of sub) { + let string = Buffer.from(data.data).toString("utf-8"); + let d = JSON.parse(string); + let respond: Function | null = null; + + if (data.reply) { + console.log("expecting reply."); + respond = (d: object) => { + data.respond(Buffer.from(JSON.stringify(d), "utf-8")); + }; + } + const camelCased = d.t.toLowerCase().replace(/_([a-z])/g, function (g) { + return g[1].toUpperCase(); + }); + console.log("envoi de ", camelCased); + this.emitter.emit(camelCased, d.d, respond); + } + }; + this.subscription.set(event, resolve); + + await Promise.race([task, fn()]); + + console.log(`finished task for ${event}`); + sub.unsubscribe(); + this.subscription.delete(event); + } +} diff --git a/src/handler/builder.ts b/src/handler/builder.ts new file mode 100644 index 0000000..148542a --- /dev/null +++ b/src/handler/builder.ts @@ -0,0 +1,19 @@ +import { SlashCommandBuilder } from "@discordjs/builders"; +import { Command, HandlerFn } from "."; + +export class CommandBuilder extends SlashCommandBuilder { + private _handler: HandlerFn; + + constructor() { + super(); + } + + handler(handler: HandlerFn): this { + this._handler = handler; + return this; + } + + build(): Command { + return { json: this.toJSON(), handler: this._handler }; + } +} diff --git a/src/handler/index.ts b/src/handler/index.ts new file mode 100644 index 0000000..8126717 --- /dev/null +++ b/src/handler/index.ts @@ -0,0 +1,57 @@ +import { REST } from "@discordjs/rest"; +import { + APIApplicationCommandInteraction, + APIInteraction, + APIInteractionResponse, + InteractionType, + RESTPostAPIApplicationCommandsJSONBody, + RESTPostAPIChatInputApplicationCommandsJSONBody, + RESTPostAPIWebhookWithTokenJSONBody, + Routes, +} from "discord-api-types/v10"; +import { rest } from "../rest"; + +export * from "./builder"; + +export type HandlerFn = ( + data: APIApplicationCommandInteraction +) => PromiseLike<APIInteractionResponse>; +export type PromiseLike<T> = T | Promise<T>; +export type Command = { + json: RESTPostAPIChatInputApplicationCommandsJSONBody; + handler: HandlerFn; +}; + +export const registerCommands = async ( + commands: Iterable<Command>, + rest: REST, + applicationId: string +) => { + for (const command of commands) { + await rest.post(Routes.applicationCommands(applicationId), { + body: command.json as RESTPostAPIApplicationCommandsJSONBody, + }); + } +}; + +export const buildHandler = (commands: Iterable<Command>) => { + let internal: Map<String, Command> = new Map(); + for (const command of commands) { + internal.set(command.json.name, command); + } + + return async (event: APIInteraction, reply?: (data: object) => void) => { + console.log("executing: ", event.data); + if (event.type === InteractionType.ApplicationCommand) { + console.log("executing: ", event.data); + let command = internal.get(event.data.name); + + if (command) { + let data = await command.handler(event); + console.log("sending reply", data); + + reply(data); + } + } + }; +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..eb8707f --- /dev/null +++ b/src/index.ts @@ -0,0 +1,52 @@ +import { EventClient } from "./events/index"; +import { + RESTPostAPIChannelMessageResult, + Routes, +} from "discord-api-types/v10"; +import { rest } from "./rest"; +import { buildHandler } from "./handler"; +import { commands } from "./commands"; + +const emitter = new EventClient(); +emitter.on("interactionCreate", buildHandler(commands)); + +emitter.on("messageCreate", async (message) => { + console.log(message); + if (message.content.toLowerCase() == "salut") { + await rest.post(Routes.channelMessages(message.channel_id), { + body: { + content: `Salut <@${message.author.id}> :wink:`, + }, + }); + } else if (message.content.toLocaleLowerCase() == "~ping") { + let t1 = new Date().getTime(); + let sentMessage = <RESTPostAPIChannelMessageResult>await rest.post( + Routes.channelMessages(message.channel_id), + { + body: { + content: `Calcul du ping...`, + }, + } + ); + let time = new Date().getTime() - t1; + + await rest.patch( + Routes.channelMessage(message.channel_id, sentMessage.id), + { + body: { + content: `Le ping de <@${sentMessage.author.id}> est de \`${time}ms\``, + }, + } + ); + } +}); + +emitter + .start({ + additionalEvents: [], + nats: { + servers: ["localhost:4222"], + }, + queue: "nova-worker-common", + }) + .catch(console.log); diff --git a/src/register.ts b/src/register.ts new file mode 100644 index 0000000..4672585 --- /dev/null +++ b/src/register.ts @@ -0,0 +1,5 @@ +import { commands } from "./commands"; +import { registerCommands } from "./handler"; +import { rest } from "./rest"; + +registerCommands(commands, rest, "807188335717384212");
\ No newline at end of file diff --git a/src/rest.ts b/src/rest.ts new file mode 100644 index 0000000..9bee0cb --- /dev/null +++ b/src/rest.ts @@ -0,0 +1,8 @@ +require('source-map-support').install(); +import { REST } from "@discordjs/rest"; + +export const rest = new REST({ + version: "10", + headers: { Authorization: "" }, + api: "http://localhost:8090/api", +}).setToken("_"); |
