]> git.puffer.fish Git - matthieu/gru.git/commitdiff
documentation and internals overhaul
authorMatthieuCoder <matthieu@matthieu-dev.xyz>
Wed, 4 Jan 2023 21:40:50 +0000 (01:40 +0400)
committerMatthieuCoder <matthieu@matthieu-dev.xyz>
Wed, 4 Jan 2023 21:40:50 +0000 (01:40 +0400)
18 files changed:
package.json
src/commands/ping.ts
src/events/client.ts [deleted file]
src/events/event-emitter.ts [deleted file]
src/events/index.ts [deleted file]
src/events/transport.ts [deleted file]
src/handler/builder.ts [deleted file]
src/handler/index.ts [deleted file]
src/index.ts
src/register.ts
src/rest.ts [deleted file]
src/sys/events/client.ts [new file with mode: 0644]
src/sys/events/index.ts [new file with mode: 0644]
src/sys/events/transport.ts [new file with mode: 0644]
src/sys/handler/builder.ts [new file with mode: 0644]
src/sys/handler/index.ts [new file with mode: 0644]
tsconfig.json
yarn.lock

index 6b31c002ad13328421932b30056d0dcbc6d7baa0..c10f672445af6a6cb6a4ffe0df40c38bcb0c0d84 100644 (file)
@@ -8,17 +8,20 @@
   "license": "Apache-2.0",
   "dependencies": {
     "@discordjs/builders": "^1.4.0",
+    "@discordjs/core": "^0.3.0",
     "@discordjs/rest": "^1.5.0",
-    "discord-api-types": "^0.37.25",
     "events": "^3.3.0",
     "glob-regex": "^0.3.2",
+    "js-logger": "^1.6.1",
     "nats": "^2.10.2",
     "ping": "^0.4.2",
-    "source-map-support": "^0.5.21"
+    "source-map-support": "^0.5.21",
+    "typed-emitter": "^2.1.0"
   },
   "devDependencies": {
     "@types/node": "^18.11.18",
     "@types/ping": "^0.4.1",
+    "discord-api-types": "^0.37.25",
     "type-fest": "^3.5.0",
     "typescript": "^4.9.4"
   },
index 9bf6bc056eba23a98e020b4d4d46c83d5848af63..a39e25a3dc68060f11ab1a46b5d370050ce65f4b 100644 (file)
@@ -5,7 +5,7 @@ import {
   ApplicationCommandType,
   InteractionResponseType,
 } from "discord-api-types/v10";
-import { CommandBuilder, HandlerFn } from "../handler";
+import { CommandBuilder, HandlerFn } from "../sys/handler";
 import { promise } from "ping";
 
 type Messages = {
diff --git a/src/events/client.ts b/src/events/client.ts
deleted file mode 100644 (file)
index 06c7903..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-import { GatewayDispatchPayload } from "discord-api-types/v10";
-import { BaseEventEmitter, HandlerFunction } from "./event-emitter";
-import { Transport, TransportOptions } from "./transport";
-import { CamelCase } from "type-fest";
-import { REST } from "@discordjs/rest";
-
-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>}`]: [
-    ExtractEvent<GatewayDispatchPayload, P>["d"]
-  ];
-};
-
-export class EventClient extends BaseEventEmitter {
-  public transport: Transport;
-  // constructs
-  constructor(private rest: REST) {
-    super();
-    this.transport = new Transport(this, rest);
-  }
-
-  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
deleted file mode 100644 (file)
index ad64cd5..0000000
+++ /dev/null
@@ -1,56 +0,0 @@
-import { EventEmitter } from "events";
-import { PascalCase } from "type-fest";
-import { Events } from ".";
-import { APIInteractionResponse } from "discord-api-types/v10";
-
-export type HandlerFunction<Args extends unknown[]> = (
-  ...args: [...Args, ...[resolve?: (data: APIInteractionResponse) => 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,
-    respond: (data: APIInteractionResponse) => void,
-    ...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
deleted file mode 100644 (file)
index 5ec7692..0000000
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./client";
diff --git a/src/events/transport.ts b/src/events/transport.ts
deleted file mode 100644 (file)
index 882ce41..0000000
+++ /dev/null
@@ -1,127 +0,0 @@
-import { connect, ConnectionOptions, NatsConnection } from "nats";
-import { EventClient, Events } from ".";
-import globRegex from "glob-regex";
-import { REST } from "@discordjs/rest";
-import {
-  APIInteractionResponse,
-  GatewayDispatchPayload,
-  GatewayInteractionCreateDispatch,
-  Routes,
-} from "discord-api-types/v10";
-import { CamelCase } from "type-fest";
-
-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, private rest: REST) {}
-
-  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: GatewayDispatchPayload = JSON.parse(string);
-        let respond: (repond: APIInteractionResponse) => void | null = null;
-        const camelCased = d.t.toLowerCase().replace(/_([a-z])/g, function (g) {
-          return g[1].toUpperCase();
-        }) as CamelCase<`${typeof d.t}`>;
-
-        if (camelCased === "integrationCreate") {
-          let interaction = d.d as GatewayInteractionCreateDispatch["d"];
-          respond = (respond: APIInteractionResponse) => {
-            if (data.reply) {
-              data.respond(Buffer.from(JSON.stringify(respond), "utf-8"));
-            } else {
-              this.rest.post(
-                Routes.webhook(interaction.channel_id, interaction.token),
-                { body: respond }
-              );
-            }
-          };
-          console.log("expecting reply.");
-        }
-
-        this.emitter.emit(camelCased, respond, d.d as any);
-      }
-    };
-    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
deleted file mode 100644 (file)
index feb5780..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-import { SlashCommandBuilder } from "@discordjs/builders";
-import { Command, HandlerFn } from ".";
-
-/**
- * Simple wrapper around the SlashCommandBuilder provided by Discord.js 
- */
-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
deleted file mode 100644 (file)
index 9852d60..0000000
+++ /dev/null
@@ -1,73 +0,0 @@
-import { REST } from "@discordjs/rest";
-import {
-  APIApplicationCommandInteraction,
-  APIInteraction,
-  APIInteractionResponse,
-  InteractionType,
-  RESTPostAPIApplicationCommandsJSONBody,
-  RESTPostAPIChatInputApplicationCommandsJSONBody,
-  Routes,
-} 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,
-  applicationId: string
-) => {
-  for (const command of commands) {
-    await rest.post(Routes.applicationCommands(applicationId), {
-      body: command.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>) => {
-  let internal: Map<String, Command> = new Map();
-  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);
-      let command = internal.get(event.data.name);
-
-      if (command) {
-        let data = await command.handler(event);
-        console.log("sending reply", data);
-
-        reply(data);
-      }
-    }
-  };
-};
index 62cfdcb3f8612b831c0fad0675358906b5f99c1c..c95e664c15535cc8e075e95e38e9e676390e72b5 100644 (file)
@@ -1,23 +1,34 @@
-import { EventClient } from "./events/index";
-import { buildHandler } from "./handler";
+import { Client } from "./sys/events";
+import { buildHandler } from "./sys/handler";
 import { commands } from "./commands";
-import { rest } from "./rest";
 
 /**
- * We instanciate our nova broken client.
+ * We instanciate our nova broker client.
  */
-const emitter = new EventClient(rest);
-
-// We register our slash command handler.
-emitter.on("interactionCreate", buildHandler(commands));
-
-// We connect ourselves to the nova nats broker.
-emitter
-  .start({
+const emitter = new Client({
+  transport: {
     additionalEvents: [],
     nats: {
       servers: ["localhost:4222"],
     },
     queue: "nova-worker-common",
-  })
-  .catch(console.log);
+  },
+  rest: {
+    api: "http://localhost:8090/api",
+  },
+});
+
+// We register our slash command handler.
+emitter.on("interactionCreate", buildHandler(commands));
+
+// Simple message handler
+emitter.on("messageCreate", (message) => {
+  if (message.content === "~ping") {
+    message.client.channels.createMessage(message.channel_id, {
+      content: `Bonjour! <@${message.author.id}>`,
+    });
+  }
+});
+
+// We connect ourselves to the nova nats broker.
+emitter.start().catch(console.log);
index af01e762b9cefb0aa0e219c616bbaa0f15f11444..92ce36a01470c36451bd0d6b3877a7903eb5925b 100644 (file)
@@ -1,7 +1,14 @@
+import { REST } from "@discordjs/rest";
 import { commands } from "./commands";
-import { registerCommands } from "./handler";
-import { rest } from "./rest";
+import { registerCommands } from "./sys/handler";
 
+const rest = new REST({
+    version: "10",
+    headers: { Authorization: "" },
+    api: "http://localhost:8090/api",
+  }).setToken("_");
+
+  
 /**
  * We register the commands with discord
  */
diff --git a/src/rest.ts b/src/rest.ts
deleted file mode 100644 (file)
index ab9ff54..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-require('source-map-support').install();
-import { REST } from "@discordjs/rest";
-
-/**
- * Rest client used to communicate with discord
- */
-export const rest = new REST({
-  version: "10",
-  headers: { Authorization: "" },
-  api: "http://localhost:8090/api",
-}).setToken("_");
diff --git a/src/sys/events/client.ts b/src/sys/events/client.ts
new file mode 100644 (file)
index 0000000..90d7447
--- /dev/null
@@ -0,0 +1,142 @@
+import {
+  APIApplicationCommandPermissionsConstant,
+  APIInteractionResponse,
+  APIInteractionResponseCallbackData,
+  GatewayDispatchPayload,
+  GatewayInteractionCreateDispatchData,
+} from "discord-api-types/v10";
+import { Transport, TransportOptions } from "./transport";
+import { CamelCase, PascalCase } from "type-fest";
+import { REST, RESTOptions } from "@discordjs/rest";
+import { EventEmitter } from "stream";
+import TypedEmitter from "typed-emitter";
+
+import { API } from "@discordjs/core";
+
+/**
+ * Maps an event name (O['t']) and a Union O and extracts alla 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 };
+export type EventName = keyof EventsHandlerArguments;
+/**
+ * Reprends a handler function with one argument
+ */
+export type HandlerFunction<Arg extends unknown[]> = (
+  ...args: Arg
+) => unknown | Promise<unknown>;
+
+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: APIInteractionResponseCallbackData) => 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
+ */
+interface ClientFunctions
+  extends EventsFunctions,
+    TypedEmitter<EventsHandlerArguments>,
+    API {}
+
+/**
+ * The real extended class is an EventEmitter.
+ */
+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 {
+  private readonly transport: Transport;
+  private readonly api: API;
+  public readonly rest: REST;
+
+  constructor(options: {
+    rest?: Partial<RESTOptions>;
+    transport: TransportOptions;
+  }) {
+    super();
+    this.rest = new REST(options.rest);
+    this.transport = new Transport(this, options.transport);
+    this.api = new API(this.rest);
+
+    // This is safe because this event is emitted by the EventEmitter itself.
+    this.on("newListener" as any, (event: EventName) => {
+      this.transport.subscribe(event);
+    });
+
+    // Using a proxy to provide the 'on...' functionality
+    return new Proxy(this, {
+      get(self, symbol) {
+        let name = symbol.toString();
+        if (name.startsWith("on") && name.length > 2) {
+          // Get the event name
+          let 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]) {
+          return self.api[symbol];
+        }
+
+        return self[symbol];
+      },
+    });
+  }
+
+  public start() {
+    return this.transport.start();
+  }
+}
diff --git a/src/sys/events/index.ts b/src/sys/events/index.ts
new file mode 100644 (file)
index 0000000..5ec7692
--- /dev/null
@@ -0,0 +1 @@
+export * from "./client";
diff --git a/src/sys/events/transport.ts b/src/sys/events/transport.ts
new file mode 100644 (file)
index 0000000..28be5cf
--- /dev/null
@@ -0,0 +1,175 @@
+import { connect, ConnectionOptions, NatsConnection, Subscription } from "nats";
+import {
+  Client,
+  EventName,
+  EventTypes,
+  EventsHandlerArguments,
+  WithIntrisics,
+} from ".";
+import globRegex from "glob-regex";
+import {
+  APIInteraction,
+  APIInteractionResponse,
+  APIInteractionResponseCallbackData,
+  GatewayDispatchPayload,
+  GatewayInteractionCreateDispatch,
+  Routes,
+} from "discord-api-types/v10";
+import { CamelCase } from "type-fest";
+
+/**
+ * Options for the nats transport layer
+ */
+export type TransportOptions = {
+  additionalEvents?: (keyof EventsHandlerArguments)[];
+  nats?: ConnectionOptions;
+  queue: string;
+};
+
+/**
+ * Transport implements all the communication to Nova using Nats
+ */
+export class Transport {
+  // Nats connection
+  private nats: NatsConnection | null = null;
+  // Current subscriptions
+  private subscriptions: Map<string, Subscription> = new Map();
+  // Current subscribed events
+  private events: Set<EventName> = new Set();
+
+  // Creats a new Transport instance.
+  constructor(
+    private emitter: Client,
+    private config: Partial<TransportOptions>
+  ) {}
+
+  /**
+   * Starts a new nats client.
+   */
+  public async start() {
+    this.nats = await connect(this.config?.nats);
+
+    for (let eventName of this.events) {
+      await this.subscribe(eventName);
+    }
+    if (this.config.additionalEvents) {
+      for (let eventName of this.config.additionalEvents) {
+        await 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.
+    let 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.
+    let topic = `nova.cache.dispatch.${dashed.toUpperCase()}`;
+
+    // To avoid having multiple subscriptions covering this event
+    // we check if each of our subscriptions covers this scope.
+    let isAlreadyPresent = [...this.subscriptions.keys()].reduce(
+      (previous, current) => {
+        if (previous) return previous;
+        let 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.
+    let regex = globRegex(topic);
+    [...this.subscriptions.keys()].map((key) => {
+      if (regex.test(key)) {
+        let subsciption = this.subscriptions.get(key);
+        if (!subsciption) {
+          return;
+        }
+
+        console.log(`unsubscribing from ${key}`);
+        subsciption.unsubscribe();
+      }
+    });
+
+    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
+    let subscription = this.nats.subscribe(topic, { queue: this.config.queue });
+    this.subscriptions.set(topic, subscription);
+    // Handle each event in the subscription stream.
+    for await (let publish of subscription) {
+      try {
+        // Decode the payload
+        let event: GatewayDispatchPayload = JSON.parse(
+          Buffer.from(publish.data).toString("utf-8")
+        );
+        // Transform the event name to a camclCased name
+        const camelCasedName = event.t
+          .toLowerCase()
+          .replace(/_([a-z])/g, function (g) {
+            return 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") {
+          let interaction = event.d;
+          let respond = async (respond: APIInteractionResponseCallbackData) => {
+            if (publish.reply) {
+              publish.respond(Buffer.from(JSON.stringify(respond), "utf-8"));
+            } else {
+              await this.emitter.interactions.reply(
+                interaction.id,
+                interaction.token,
+                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 (e) {}
+    }
+  }
+}
diff --git a/src/sys/handler/builder.ts b/src/sys/handler/builder.ts
new file mode 100644 (file)
index 0000000..feb5780
--- /dev/null
@@ -0,0 +1,22 @@
+import { SlashCommandBuilder } from "@discordjs/builders";
+import { Command, HandlerFn } from ".";
+
+/**
+ * Simple wrapper around the SlashCommandBuilder provided by Discord.js 
+ */
+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/sys/handler/index.ts b/src/sys/handler/index.ts
new file mode 100644 (file)
index 0000000..c9a5343
--- /dev/null
@@ -0,0 +1,74 @@
+import { REST } from "@discordjs/rest";
+import {
+  APIApplicationCommandInteraction,
+  APIInteraction,
+  APIInteractionResponse,
+  APIInteractionResponseCallbackData,
+  InteractionType,
+  RESTPostAPIApplicationCommandsJSONBody,
+  RESTPostAPIChatInputApplicationCommandsJSONBody,
+  Routes,
+} 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<APIInteractionResponseCallbackData>;
+
+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,
+  applicationId: string
+) => {
+  await rest.post(Routes.applicationCommands(applicationId), {
+    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>) => {
+  let internal: Map<String, Command> = new Map();
+  for (const command of commands) {
+    internal.set(command.json.name, command);
+  }
+
+  return async (
+    event: APIInteraction,
+    reply?: (data: APIInteractionResponseCallbackData) => 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);
+      }
+    }
+  };
+};
index e3d5fd803243a8a7240c8ca1107efb3d0bffb9b8..7525d1930984a667366cfae5bdae2eadb0d0d2b3 100644 (file)
@@ -5,7 +5,8 @@
         "target": "ES5",
         "sourceMap": true,
         "moduleResolution": "node",
-        "skipLibCheck": true
+        "skipLibCheck": true,
+        "incremental": true
     },
     
     "exclude": ["node_modules"]
index c276afd2acee31d8bd42e585507f0930ceb31536..0efdc27337e17222c31c03407f4f0439d6c3ec49 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
   resolved "https://registry.yarnpkg.com/@discordjs/collection/-/collection-1.3.0.tgz#65bf9674db72f38c25212be562bb28fa0dba6aa3"
   integrity sha512-ylt2NyZ77bJbRij4h9u/wVy7qYw/aDqQLWnadjvDqW/WoWCxrsX6M3CIw9GVP5xcGCDxsrKj5e0r5evuFYwrKg==
 
+"@discordjs/core@^0.3.0":
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/@discordjs/core/-/core-0.3.0.tgz#799ab3ad38dc2c037ac4567a3948e47b93ea2d31"
+  integrity sha512-yp3H+NnaZ/sg0Z4PIVgfH4Ab88MwkXxnzwG5lNxjYiwp7+8qkYjb726qf3OxZOhFXQFt7gYaAaBevHgmPz9vjA==
+  dependencies:
+    "@discordjs/rest" "^1.5.0"
+    "@discordjs/ws" "^0.6.0"
+    "@vladfrangu/async_event_emitter" "^2.1.2"
+    discord-api-types "^0.37.23"
+
 "@discordjs/rest@^1.5.0":
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/@discordjs/rest/-/rest-1.5.0.tgz#dc15474ab98cf6f31291bf61bbc72bcf4f30cea2"
   resolved "https://registry.yarnpkg.com/@discordjs/util/-/util-0.1.0.tgz#e42ca1bf407bc6d9adf252877d1b206e32ba369a"
   integrity sha512-e7d+PaTLVQav6rOc2tojh2y6FE8S7REkqLldq1XF4soCx74XB/DIjbVbVLtBemf0nLW77ntz0v+o5DytKwFNLQ==
 
+"@discordjs/ws@^0.6.0":
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/@discordjs/ws/-/ws-0.6.0.tgz#bd479c65c469552530cc0c54908d6c4722731272"
+  integrity sha512-CGXmDnskIWyitBejQG07ao6MTkxK5OnFJQLan/cNIoMF61mlbTXpcKGnyrD7dhE7B/q2MEWPKfpuYXtFBog1cg==
+  dependencies:
+    "@discordjs/collection" "^1.3.0"
+    "@discordjs/rest" "^1.5.0"
+    "@discordjs/util" "^0.1.0"
+    "@sapphire/async-queue" "^1.5.0"
+    "@types/ws" "^8.5.3"
+    "@vladfrangu/async_event_emitter" "^2.1.2"
+    discord-api-types "^0.37.23"
+    tslib "^2.4.1"
+    ws "^8.11.0"
+
 "@sapphire/async-queue@^1.5.0":
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/@sapphire/async-queue/-/async-queue-1.5.0.tgz#2f255a3f186635c4fb5a2381e375d3dfbc5312d8"
@@ -61,7 +86,7 @@
   resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276"
   integrity sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==
 
-"@types/node@^18.11.18":
+"@types/node@*", "@types/node@^18.11.18":
   version "18.11.18"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.18.tgz#8dfb97f0da23c2293e554c5a50d61ef134d7697f"
   integrity sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==
   resolved "https://registry.yarnpkg.com/@types/ping/-/ping-0.4.1.tgz#98f9c20be196ca6c3c2639528b7e1827b17e84be"
   integrity sha512-q/D+xQvoqrHvntvz2A0Pb0ImYwnN3zakluUp8O2qoogGoVMVbdY2K/ulxHcCh9TzYzVoojayHBa9gYQDIZ4v0A==
 
+"@types/ws@^8.5.3":
+  version "8.5.4"
+  resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.4.tgz#bb10e36116d6e570dd943735f86c933c1587b8a5"
+  integrity sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==
+  dependencies:
+    "@types/node" "*"
+
+"@vladfrangu/async_event_emitter@^2.1.2":
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/@vladfrangu/async_event_emitter/-/async_event_emitter-2.1.2.tgz#85f04380d474edd1e1e5bd380dee04bc933fe995"
+  integrity sha512-s/L3xf+BPb8EUOcVeqQ1pUwZNaWGl0br/GCCsdui5DTqn8bpGGu1S2JRTO3cyAytWObieetgTzOPuDN8nwNtGA==
+
 buffer-from@^1.0.0:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
@@ -122,6 +159,11 @@ inherits@^2.0.3:
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
 
+js-logger@^1.6.1:
+  version "1.6.1"
+  resolved "https://registry.yarnpkg.com/js-logger/-/js-logger-1.6.1.tgz#8f09671b515e4a6f31dced8fdb8923432e2c60af"
+  integrity sha512-yTgMCPXVjhmg28CuUH8CKjU+cIKL/G+zTu4Fn4lQxs8mRFH/03QTNvEFngcxfg/gRDiQAOoyCKmMTOm9ayOzXA==
+
 lodash@^4.17.21:
   version "4.17.21"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
@@ -176,6 +218,13 @@ readable-web-to-node-stream@^3.0.2:
   dependencies:
     readable-stream "^3.6.0"
 
+rxjs@^7.5.2:
+  version "7.8.0"
+  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.0.tgz#90a938862a82888ff4c7359811a595e14e1e09a4"
+  integrity sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==
+  dependencies:
+    tslib "^2.1.0"
+
 safe-buffer@~5.2.0:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
@@ -227,7 +276,7 @@ ts-mixer@^6.0.2:
   resolved "https://registry.yarnpkg.com/ts-mixer/-/ts-mixer-6.0.2.tgz#3e4e4bb8daffb24435f6980b15204cb5b287e016"
   integrity sha512-zvHx3VM83m2WYCE8XL99uaM7mFwYSkjR2OZti98fabHrwkjsCvgwChda5xctein3xGOyaQhtTeDq/1H/GNvF3A==
 
-tslib@^2.4.1:
+tslib@^2.1.0, tslib@^2.4.1:
   version "2.4.1"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e"
   integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==
@@ -242,6 +291,13 @@ type-fest@^3.5.0:
   resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-3.5.0.tgz#df7b2ef54ea775163c56d087b33e901ce9d657f7"
   integrity sha512-bI3zRmZC8K0tUz1HjbIOAGQwR2CoPQG68N5IF7gm0LBl8QSNXzkmaWnkWccCUL5uG9mCsp4sBwC8SBrNSISWew==
 
+typed-emitter@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/typed-emitter/-/typed-emitter-2.1.0.tgz#ca78e3d8ef1476f228f548d62e04e3d4d3fd77fb"
+  integrity sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==
+  optionalDependencies:
+    rxjs "^7.5.2"
+
 typescript@^4.9.4:
   version "4.9.4"
   resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78"
@@ -268,3 +324,8 @@ web-streams-polyfill@^3.2.1:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz#71c2718c52b45fd49dbeee88634b3a60ceab42a6"
   integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==
+
+ws@^8.11.0:
+  version "8.11.0"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143"
+  integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==