bra1n/judgebot

View on GitHub
modules/ipg.ts

Summary

Maintainability
B
4 hrs
Test Coverage
import {
  ApplicationCommandOptionChoiceData,
  ApplicationCommandOptionType,
  AutocompleteInteraction,
  CommandInteraction,
} from "discord.js";
import * as utils from "../utils.js";
import fetch from "node-fetch";
import _ from "lodash";
const log = utils.getLogger("ipg");
const IPG_ADDRESS =
  process.env.IPG_ADDRESS ||
  "https://raw.githubusercontent.com/hgarus/mtgdocs/master/docs/ipg.json";
import {
  DApplicationCommand,
  Discord,
  Slash,
  SlashChoice,
  SlashOption,
  SlashOptionOptions,
  MetadataStorage,
} from "discordx";
import { EmbedBuilder } from "@discordjs/builders";

interface IpgEntry {
  title: string;
  url: string;
  text: string;
}

interface IpgChapter extends IpgEntry {
  sections: string[];
}

interface IpgSection extends IpgEntry {
  subsections: string[];
  penalty: string;
  subsectionContents: Record<string, IpgSubSection>;
}

interface IpgSubSection {
  title: string;
  text: string[];
}

type IpgData = Record<string, IpgChapter | IpgSection>;

@Discord()
export default class IPG {
  ipgData: IpgData;
  suggestions: ApplicationCommandOptionChoiceData[];
  static location = "http://blogs.magicjudges.org/rules/ipg";
  static maxLength = 2040;
  static thumbnail =
    "https://assets.magicjudges.org/judge-banner/images/magic-judge.png";
  static aliases: Record<string, string> = {
    definition: "1.1",
    applying: "1.1",
    backup: "1.4",
    randomizing: "1.3",
    random: "1.3",
    mt: "2.1",
    trigger: "2.1",
    "l@ec": "2.2",
    laec: "2.2",
    hce: "2.3",
    dec: "2.3",
    mulligan: "2.4",
    mpe: "2.4",
    grv: "2.5",
    ftmgs: "2.6",
    f2mgs: "2.6",
    gpe: "2",
    te: "3",
    general: "1",
    tardiness: "3.1",
    tardy: "3.1",
    oa: "3.2",
    sp: "3.3",
    slowplay: "3.3",
    is: "3.9",
    shuffling: "3.9",
    dp: "3.5",
    deckproblem: "3.5",
    dlp: "3.4",
    decklistproblem: "3.4",
    lpv: "3.6",
    cpv: "3.7",
    mc: "3.8",
    usc: "4",
    uscminor: "4.1",
    uscmajor: "4.2",
    idw: "4.3",
    idaw: "4.3",
    bribery: "4.4",
    wagering: "4.4",
    baw: "4.4",
    ab: "4.5",
    aggressive: "4.5",
    theft: "4.6",
    totm: "4.6",
    tot: "4.6",
    stalling: "4.7",
    cheating: "4.8",
  };

  constructor(initialize: boolean = true) {
    this.ipgData = {};
    this.suggestions = [];
    if (initialize) {
      setTimeout(this.init.bind(this));
    }
  }

  /**
   * Run startup for this module
   */
  async init() {
    let res;
    try {
      res = await fetch(IPG_ADDRESS);
    } catch (err) {
      log.error(`Error loading IPG: ${err}`);
      return;
    }
    if (res.status === 200) {
      this.ipgData = (await res.json()) as IpgData;
      log.info("IPG Successfully Parsed");
    } else {
      log.error("Error loading IPG, server returned status code " + res.status);
    }

    for (let key in this.ipgData) {
      this.suggestions.push({
        value: key,
        name: this.ipgData[key].title,
      });
    }
    // for (let key in IPG.aliases){
    //     this.suggestions.push({
    //         value: key,
    //         name: this.ipgData[IPG.aliases[key]].title
    //     });
    // }
  }

  formatPreview(entry: IpgEntry | IpgSubSection) {
    let text: string;
    if (Array.isArray(entry.text)) {
      text = entry.text.join(" ");
    } else {
      text = entry.text;
    }
    return `**${entry.title}**\n${text}`;
  }

  // IPG Chapter (like "2")
  formatChapterEntry(entry: IpgChapter) {
    const text =
      entry.text || this.formatPreview(this.ipgData[entry.sections[0]]);

    return new EmbedBuilder()
      .setTitle(`IPG - ${entry.title}`)
      .setDescription(
        _.truncate(text, { length: IPG.maxLength, separator: "\n" })
      )
      .setThumbnail(IPG.thumbnail)
      .setURL(entry.url)
      .setFields({
        name: "Available Sections",
        value: entry.sections
          .map((s) => `• ${this.ipgData[s].title}`)
          .join("\n"),
      });
  }

  // IPG Section (like "2.1")
  formatSectionEntry(entry: IpgSection) {
    const text =
      entry.text ||
      this.formatPreview(entry.subsectionContents[entry.subsections[0]]);
    const embed = new EmbedBuilder()
      .setTitle(`IPG - ${entry.title}`)
      .setDescription(
        _.truncate(text, { length: IPG.maxLength, separator: "\n" })
      )
      .setThumbnail(IPG.thumbnail)
      .setURL(entry.url);
    if (entry.penalty) {
      embed.addFields({ name: "Penalty", value: entry.penalty });
    }
    if (entry.subsections.length) {
      embed.addFields({
        name: "Available Subsections",
        value: entry.subsections.map((s) => `• ${s}`).join("\n"),
      });
    }

    return embed;
  }

  // IPG Subsection (like "2.1 definition")
  formatSubsectionEntry(
    sectionEntry: IpgSection,
    subsectionEntry: IpgSubSection
  ) {
    const otherSections = sectionEntry.subsections.filter(
      (s) => s !== _.kebabCase(subsectionEntry.title)
    );

    return new EmbedBuilder()
      .setTitle(`IPG - ${sectionEntry.title} - ${subsectionEntry.title}`)
      .setDescription(
        _.truncate(subsectionEntry.text.join("\n\n"), {
          length: IPG.maxLength,
          separator: "\n",
        })
      )
      .setThumbnail(IPG.thumbnail)
      .setFooter({
        text: `Other available subsections: ${otherSections.join(", ")}`,
      })
      .setURL(
        sectionEntry.url +
          "#" +
          subsectionEntry.title.toLowerCase().replace(/ /g, "-")
      );
  }

  // main lookup method
  find(lookup: string, subsection?: string) {
    const entry = this.ipgData[lookup];
    if (!entry) {
      let availableEntries = _.keys(this.ipgData);
      availableEntries.sort();
      return new EmbedBuilder()
        .setTitle("IPG - Error")
        .setDescription("These parameters don't match any entries in the IPG.")
        .setColor(0xff0000)
        .setFields({
          name: "Available Chapters",
          value: this.getChapters(),
        });
    }

    // This is a type guard, since subsections distinguishes between chapters and sections
    if ("subsections" in entry) {
      if (
        subsection &&
        entry.subsections &&
        entry.subsectionContents[subsection]
      ) {
        // we have a second parameter and available subsections that match it
        return this.formatSubsectionEntry(
          entry,
          entry.subsectionContents[subsection]
        );
      } else {
        return this.formatSectionEntry(entry);
      }
    } else {
      return this.formatChapterEntry(entry);
    }
  }

  getChapters(): string {
    return Object.values(this.ipgData)
      .filter((c) => c.title.match(/^\d+\s/))
      .map((c) => "• " + c.title.replace(/^(\d+)\s/, "$1. "))
      .join("\n");
  }

  @Slash({
    name: "ipg",
    description: "Show an entry from the Infraction Procedure Guide",
  })
  async ipg(
    @SlashOption({
      name: "section",
      description:
        'IPG section number e.g. "2.5", or an alias for one e.g. "grv" (Game Rule Violation)',
      type: ApplicationCommandOptionType.String,
      autocomplete: async function (
        this: IPG,
        interaction: AutocompleteInteraction,
        cmd: DApplicationCommand
      ) {
        const query = interaction.options.data.filter(
          (opt) => opt.name === "section"
        )[0];
        await interaction.respond(
          this.suggestions
            .filter((hit) => hit.name.startsWith(query.value as string))
            .slice(0, 25)
        );
      },
    })
    lookup: string,
    @SlashOption({
      name: "subsection",
      description: 'Subsection name, e.g. "philosophy"',
      required: false,
    })
    subsection: string,
    interaction: CommandInteraction
  ) {
    const embed = this.find(lookup, subsection);
    await interaction.reply({
      embeds: [embed],
    });
  }
}