summaryrefslogtreecommitdiff
path: root/autofeur-nova/src
diff options
context:
space:
mode:
Diffstat (limited to 'autofeur-nova/src')
-rw-r--r--autofeur-nova/src/algo.mts80
-rw-r--r--autofeur-nova/src/index.mts42
-rw-r--r--autofeur-nova/src/preprocess.mts88
-rw-r--r--autofeur-nova/src/sys/events/client.mts149
-rw-r--r--autofeur-nova/src/sys/events/index.mts1
-rw-r--r--autofeur-nova/src/sys/events/transport.mts185
-rw-r--r--autofeur-nova/src/sys/handler/builder.mts18
-rw-r--r--autofeur-nova/src/sys/handler/index.mts73
8 files changed, 636 insertions, 0 deletions
diff --git a/autofeur-nova/src/algo.mts b/autofeur-nova/src/algo.mts
new file mode 100644
index 0000000..89996ca
--- /dev/null
+++ b/autofeur-nova/src/algo.mts
@@ -0,0 +1,80 @@
+import { readFileSync } from "fs";
+import { request } from "undici";
+
+const phonemize = (grapheme: string) =>
+ request(
+ `http://localhost:5000?grapheme=${encodeURIComponent(grapheme)}`
+ ).then((x) => x.body.text());
+
+let data: {
+ word: string;
+ phoneme: string;
+ partials: string[];
+}[] = JSON.parse(readFileSync("./data.json").toString("utf8"));
+
+const cutWord = (sentence: string) => {
+ let lastWord = sentence.split(" ").slice(-1)[0].replace(/(\?)/g, "");
+ return phonemize(lastWord);
+};
+
+export const match = async (sentence: string) => {
+ let scores: { complete: string; score: number }[] = [];
+ let sentenceWord = await cutWord(sentence);
+ console.debug("handling word phoneme = ", sentenceWord);
+
+ for (const { phoneme, word, partials } of data) {
+ let maxIter = Math.min(sentenceWord.length, phoneme.length);
+ let overlap = 0;
+ for (let i = maxIter - 1; i !== 0; i--) {
+ let part1 = sentenceWord[sentenceWord.length - i - 1];
+ let part2 = phoneme[i];
+ if (part1 !== part2) {
+ console.log("\t\tnot eq", part1, part2);
+ break;
+ }
+ overlap++;
+ }
+ console.debug("\ttesting with word = ", overlap, word, phoneme);
+
+ /*
+ for (let i = 1; i < phoneme.length; i++) {
+ // add n last characters from the phoneme
+ let add = phoneme.slice(phoneme.length - i, phoneme.length);
+ console.debug(
+ "\t\ttesting match with = ",
+ add,
+ " add = ",
+ sentenceWord + add
+ );
+
+ // we matched a phoneme
+ if (phoneme == sentenceWord + add) {
+ let score = 1 / (i / phoneme.length);
+
+ // next, we need to find the completion of the word
+ // this is relatively easy since we only need to
+ let phonemeIndex = partials.indexOf(add);
+
+ if (phonemeIndex == -1) {
+ // cannot find the comlpetion count.
+ // default to index
+ console.log("couldn't find corresponding cut", add);
+ phonemeIndex = word.length + 1;
+ }
+
+ let complete = word.slice(word.length - phonemeIndex - 1, word.length);
+
+ console.log("\t\tmatched with score = ", score, " complete = ", complete);
+
+ // need to change to the cut-ed version.
+ scores.push({ score, complete: `${complete} (${word})` });
+ }
+ }*/
+ }
+ return null;
+ let resp = scores.sort((a, b) => b.score - a.score);
+ return resp[Math.floor(Math.random() * resp.length)]?.complete;
+};
+
+
+match("quoi"); \ No newline at end of file
diff --git a/autofeur-nova/src/index.mts b/autofeur-nova/src/index.mts
new file mode 100644
index 0000000..37f1343
--- /dev/null
+++ b/autofeur-nova/src/index.mts
@@ -0,0 +1,42 @@
+import "source-map-support";
+import { Client } from "./sys/events/client.mjs";
+import {
+ GatewayMessageCreateDispatch,
+ RESTPostAPIChannelMessageJSONBody,
+ Routes,
+} from "discord-api-types/v10";
+import { match } from "./algo.mjs";
+
+(async () => {
+ const emitter = new Client({
+ transport: {
+ additionalEvents: [],
+ nats: {
+ servers: ["localhost:4222"],
+ },
+ queue: "nova-worker-common",
+ },
+ rest: {
+ api: "http://localhost:8090/api",
+ },
+ });
+
+ emitter.on(
+ "messageCreate",
+ async (message: GatewayMessageCreateDispatch["d"]) => {
+ if (message.author.id === "807188335717384212") return;
+ let response = await match(message.content);
+ if (response) {
+ await emitter.rest.post(Routes.channelMessages(message.channel_id), {
+ body: {
+ content: response,
+ message_reference: { message_id: message.id },
+ } as RESTPostAPIChannelMessageJSONBody,
+ });
+ }
+ }
+ );
+
+ // We connect ourselves to the nova nats broker.
+ await emitter.start();
+})();
diff --git a/autofeur-nova/src/preprocess.mts b/autofeur-nova/src/preprocess.mts
new file mode 100644
index 0000000..1be5693
--- /dev/null
+++ b/autofeur-nova/src/preprocess.mts
@@ -0,0 +1,88 @@
+import { writeFile, writeFileSync } from "fs";
+import { request } from "undici";
+
+const phonemize = (grapheme: string) =>
+ request(
+ `http://localhost:5000?grapheme=${encodeURIComponent(grapheme)}`
+ ).then((x) => x.body.text());
+
+let jsonData: {
+ word: string;
+ phoneme: string;
+ partials: Record<string, string>;
+}[] = [];
+
+let words: string[] = [
+ "ta mere",
+ "tapis",
+ "taper",
+ "tare",
+ "tabasser",
+ "tabouret",
+ "rigole",
+ "amène",
+ "atchoum",
+ "abracadabra",
+ "abeille",
+ "alibaba",
+ "arnaque",
+ "maison",
+ "nombril",
+ "lapin",
+ "ouistiti",
+ "wifi",
+ "uifi",
+ "ouisky",
+ "uisky",
+ "renard",
+ "requin",
+ "repas",
+ "retard",
+ "coiffeur",
+ "coiffeuse",
+ "kirikou",
+ "kiri",
+ "western",
+ "un deux",
+ "hein deux",
+ "deu trois",
+ "yoplait",
+ "avalanche",
+ "moisissure",
+ "moisson",
+ "moineau",
+ "école",
+ "commentaire",
+ "quantificateur",
+ "commandant",
+ "claire chazal",
+ "tornade",
+ "bottes",
+ "bonsoir pariiiss",
+ "courtois",
+ "facteur",
+ "gérard",
+ "quoidrilatère",
+ "pepe",
+ "surfeur",
+ "toilettes",
+ "lebron james",
+ "c'est de la merde"
+];
+
+(async () => {
+ for (const word of words) {
+ let phoneme = await phonemize(word);
+ let partials: Record<string, string> = {};
+
+ for (let i = 3; i <= word.length; i++) {
+ // add n last characters from the phoneme
+ let add = word.slice(word.length - i, word.length);
+ partials[add] = await phonemize(add);
+ }
+
+ jsonData.push({ phoneme, word, partials });
+ }
+
+ writeFileSync("./data.json", JSON.stringify(jsonData));
+})();
diff --git a/autofeur-nova/src/sys/events/client.mts b/autofeur-nova/src/sys/events/client.mts
new file mode 100644
index 0000000..dac0266
--- /dev/null
+++ b/autofeur-nova/src/sys/events/client.mts
@@ -0,0 +1,149 @@
+import {EventEmitter} from 'node:events';
+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 TypedEventEmitter from 'typed-emitter';
+import {API} from '@discordjs/core';
+import {Transport, type TransportOptions} from './transport.mjs';
+
+/**
+ * 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 &
+ TypedEventEmitter<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/autofeur-nova/src/sys/events/index.mts b/autofeur-nova/src/sys/events/index.mts
new file mode 100644
index 0000000..79bac17
--- /dev/null
+++ b/autofeur-nova/src/sys/events/index.mts
@@ -0,0 +1 @@
+export * from './client.mjs';
diff --git a/autofeur-nova/src/sys/events/transport.mts b/autofeur-nova/src/sys/events/transport.mts
new file mode 100644
index 0000000..855e186
--- /dev/null
+++ b/autofeur-nova/src/sys/events/transport.mts
@@ -0,0 +1,185 @@
+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 "./index.mjs";
+
+/**
+ * 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/autofeur-nova/src/sys/handler/builder.mts b/autofeur-nova/src/sys/handler/builder.mts
new file mode 100644
index 0000000..2d4e5e7
--- /dev/null
+++ b/autofeur-nova/src/sys/handler/builder.mts
@@ -0,0 +1,18 @@
+import {SlashCommandBuilder} from '@discordjs/builders';
+import {type Command, type HandlerFn} from './index.mjs';
+
+/**
+ * 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/autofeur-nova/src/sys/handler/index.mts b/autofeur-nova/src/sys/handler/index.mts
new file mode 100644
index 0000000..bc9a81a
--- /dev/null
+++ b/autofeur-nova/src/sys/handler/index.mts
@@ -0,0 +1,73 @@
+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.mjs';
+
+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);
+ }
+ }
+ };
+};