diff options
Diffstat (limited to 'autofeur-nova/src')
| -rw-r--r-- | autofeur-nova/src/algo.mts | 80 | ||||
| -rw-r--r-- | autofeur-nova/src/index.mts | 42 | ||||
| -rw-r--r-- | autofeur-nova/src/preprocess.mts | 88 | ||||
| -rw-r--r-- | autofeur-nova/src/sys/events/client.mts | 149 | ||||
| -rw-r--r-- | autofeur-nova/src/sys/events/index.mts | 1 | ||||
| -rw-r--r-- | autofeur-nova/src/sys/events/transport.mts | 185 | ||||
| -rw-r--r-- | autofeur-nova/src/sys/handler/builder.mts | 18 | ||||
| -rw-r--r-- | autofeur-nova/src/sys/handler/index.mts | 73 | 
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); +			} +		} +	}; +};  | 
