CRBT-Team/Purplet

View on GitHub
packages/purplet/src/lib/GatewayBot.ts

Summary

Maintainability
A
0 mins
Test Coverage
import { Logger } from '@paperdave/logger';
import { asyncMap, deferred } from '@paperdave/utils';
import type { GatewayOptions } from '@purplet/gateway';
import { Gateway, GatewayExitError } from '@purplet/gateway';
import { deepEqual } from 'fast-equals';
import type {
  APIGuild,
  GatewayDispatchPayload,
  GatewayIntentBits,
  GatewayPresenceUpdateData,
  GatewayReadyDispatchData,
  RESTPutAPIApplicationCommandsJSONBody,
} from 'purplet/types';
import { GatewayDispatchEvents } from 'purplet/types';
import { rest, setGlobalEnv } from './env';
import { FeatureLoader } from './FeatureLoader';
import type { Feature } from './hook';
import {
  $applicationCommands,
  $dispatch,
  $initialize,
  $intents,
  $interaction,
  $presence,
} from './hook-core';
import { mergeCommands, mergeIntents, mergePresence } from './hook-core-merge';
import { runHook } from './hook-run';
import { errorFromGatewayClientExitError, errorTooManyGuilds } from '../cli/errors';
import { $gatewayEvent } from '../hooks';
import { markFeatureInternal } from '../internal';
import type { InteractionResponse } from '../structures';
import { ApplicationFlagsBitfield, createInteraction, User } from '../structures';
import type { Cleanup } from '../utils/types';

export interface GatewayBotOptions {
  /** Bot Token. */
  token: string;
  /** Initial list of features that this bot has. */
  features?: Feature[];
  /**
   * If set to true, assumes a development-mode style where commands are deployed per-guild. Do not
   * set while in production.
   */
  deployGuildCommands?: boolean;
  /**
   * If set to true alongside `deployGuildCommands`, will also clear all commands. Do not set while
   * in production.
   */
  deployGuildCommandsCleanup?: boolean;
  /** Rules for command deployment if `deployGuildCommands` is enabled. */
  guildRules?: AllowedGuildRules;
  /** Bot sharding information. */
  shard?: [shard_id: number, shard_count: number];
}

export interface PatchFeatureInput {
  add?: Feature[];
  remove?: Feature[];
}

export interface AllowedGuildRules {
  include?: string[];
  exclude?: string[];
}

export type CreateGatewayClientResult = [Gateway, GatewayReadyDispatchData];

async function createGatewayClient(identify: GatewayOptions) {
  const [promise, resolve, reject] = deferred<CreateGatewayClientResult>();

  const client = new Gateway(identify);

  function errorHandler(e: Error) {
    if (e instanceof GatewayExitError) {
      reject(errorFromGatewayClientExitError(e, client));
    } else {
      reject(e);
    }
  }

  function readyHandler(ready: GatewayReadyDispatchData) {
    resolve([client, ready]);
    client.off('error', errorHandler);
    client.off(GatewayDispatchEvents.Ready, readyHandler);
  }

  client.on('error', errorHandler);
  client.on(GatewayDispatchEvents.Ready, readyHandler);

  return promise;
}

/**
 * GatewayBot is a class that contains a feature loader and a gateway client, and properly
 * implements the 6 core hooks.
 */
// TODO: Rename this class to avoid confusion with `Gateway`
// TODO: split off command deploying logic to a `GuildCommandManager` class
export class GatewayBot {
  features = new FeatureLoader();
  gateway: Gateway | null = null;

  #application?: { id: string; flags: ApplicationFlagsBitfield };
  #user?: User;
  #running = false;
  #cleanupInitializeHook: Cleanup;
  #cachedIntents?: GatewayIntentBits;
  #cachedPresence?: GatewayPresenceUpdateData;
  #cachedCommandData?: RESTPutAPIApplicationCommandsJSONBody;

  get application() {
    if (!this.#application) {
      throw new Error('GatewayBot.application is not yet ready');
    }
    return this.#application;
  }

  get user() {
    if (!this.#user) {
      throw new Error('GatewayBot.user is not yet ready');
    }
    return this.#user;
  }

  get id() {
    return this.user.id;
  }

  constructor(readonly options: GatewayBotOptions) {
    if (this.options.features) {
      this.features.add(this.options.features);
    }
    if (this.options.deployGuildCommands) {
      this.features.add([
        markFeatureInternal(
          'dev.deployGuildCommands',
          $gatewayEvent('GUILD_CREATE', guild => {
            this.updateApplicationCommandsGuild(guild);
          })
        ),
      ]);
    }
  }

  async start() {
    if (this.#running) {
      throw new Error('GatewayBot is already running');
    }
    this.#running = true;

    Logger.debug(`starting gateway bot, guildCommands=${this.options.deployGuildCommands}`);
    this.#cleanupInitializeHook = await runHook(this.features, $initialize, undefined);
    await this.startClient();
  }

  private async startClient() {
    // Get gateway configuration
    const [intents, presence] = await Promise.all([
      this.#cachedIntents ?? runHook(this.features, $intents, mergeIntents),
      this.#cachedPresence ?? runHook(this.features, $presence, mergePresence),
    ]);

    this.#cachedIntents = intents;
    this.#cachedPresence = presence;

    // Create gateway client
    const [gateway, readyData] = await createGatewayClient({
      token: this.options.token,
      shard: this.options.shard,
      intents,
      presence,
    });
    this.gateway = gateway;
    this.#user = new User(readyData.user);

    // TODO: implement Application class
    this.#application = {
      id: readyData.application.id,
      flags: new ApplicationFlagsBitfield(readyData.application.flags),
    };

    setGlobalEnv({
      application: this.#application,
      botUser: this.#user,
      gateway,
    });

    // Dispatch Hooks
    this.gateway.on('*', (payload: GatewayDispatchPayload) => {
      runHook(this.features, $dispatch, payload);
    });

    // Interaction hooks
    this.gateway.on(GatewayDispatchEvents.InteractionCreate, i => {
      const responseHandler = async (response: InteractionResponse) => {
        await rest.interactionResponse.createInteractionResponse({
          interactionId: i.id,
          interactionToken: i.token,
          body: {
            type: response.type,
            // @ts-expect-error casting from unknown to interaction response data
            data: response.data,
          },
          files: response.files,
        });
      };

      const interaction = createInteraction(i, responseHandler);
      runHook(this.features, $interaction, interaction);
    });

    if (this.options.deployGuildCommands) {
      this.#cachedCommandData = await runHook(this.features, $applicationCommands, mergeCommands);
      await this.updateCommands(this.#cachedCommandData);
    }
  }

  private isGuildAllowed(id: string) {
    const { include = [], exclude = [] } = this.options.guildRules ?? {};
    if (include.length > 0 && !include.includes(id)) {
      return false;
    }
    if (exclude.length > 0 && exclude.includes(id)) {
      return false;
    }
    return true;
  }

  private async updateCommands(commands: RESTPutAPIApplicationCommandsJSONBody) {
    if (commands.length === 0) {
      Logger.debug('there are no application commands');
      return;
    }

    this.#cachedCommandData = commands;

    const guildList = await rest.user
      .getCurrentUserGuilds()
      .then(guilds => guilds.filter(x => this.isGuildAllowed(x.id)));

    if (guildList.length > 75) {
      throw errorTooManyGuilds();
    }
    if (guildList.length >= 5) {
      Logger.warn(
        `You have ${guildList.length} guilds on your development bot. Many guilds can slow down the bot significantly, as commands are registered per-guild during development.`
      );
    }

    await asyncMap(guildList, guild => {
      this.updateApplicationCommandsGuild(guild);
    });

    Logger.debug('development mode app command push done');
  }

  private async updateApplicationCommandsGuild(guild: Pick<APIGuild, 'name' | 'id'>) {
    try {
      await rest.applicationCommand.bulkOverwriteGuildApplicationCommands({
        guildId: guild.id,
        applicationId: this.id,
        body: this.#cachedCommandData!,
      });
      Logger.info(`updated commands on ${guild.name}`);
    } catch (error) {
      Logger.warn(`could not update commands on ${guild.name} (${guild.id})`);
    }
  }

  async close() {
    await Promise.all([
      //
      this.#cleanupInitializeHook?.(),
      this.gateway?.close(),
    ]);
  }

  async patchFeatures({ add = [], remove = [] }: PatchFeatureInput) {
    this.features.remove(remove);
    const coreFeaturesAdded = this.features.add(add);

    Logger.debug(`patching features, ${add.length} add, ${remove.length} remove.`);

    if (!this.#running) {
      return;
    }

    // TODO: clear lifecycle, but maybe that should be done in .remove()?
    await runHook(coreFeaturesAdded, $initialize, undefined);

    const newIntents = await runHook(this.features, $intents, mergeIntents);
    const newPresence = await runHook(this.features, $presence, mergePresence);
    const newCommandData = await runHook(this.features, $applicationCommands, mergeCommands);

    if (newIntents !== this.#cachedIntents) {
      this.#cachedIntents = newIntents;
      this.gateway?.close();
      await this.startClient();
    } else if (!deepEqual(newPresence, this.#cachedPresence)) {
      this.#cachedPresence = newPresence;
      this.gateway?.updatePresence(newPresence);
    }

    if (this.options.deployGuildCommands) {
      if (!deepEqual(newCommandData, this.#cachedCommandData)) {
        this.#cachedCommandData = newCommandData;
        this.updateCommands(newCommandData);
      }
    }
  }
}