<file_sep>import { GraphQLClient } from 'graphql-request';

export const client = new GraphQLClient('https://graphql.anilist.co/');
<file_sep>import { CmdCategory } from '@prisma/client';
import { kaguya_sleep_gif } from '../../shared/assets';
import { baseEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';

const goodnight = new Command({
  name: 'goodnight',
  description: 'Greets goodnight.',
  category: CmdCategory.FUN,
  aliases: ['gn'],
  args: [{ name: 'user', optional: true, multi: true }],

  async execute({ channel, mentions, author }) {
    let message;
    if (!mentions.users.size) {
      message = `Goodnight!`;
    } else {
      const userNames: string[] = [];
      mentions.users.forEach((user) => userNames.push(user.toString()));
      message =
        userNames.length > 1
          ? `**${author.username}** wishes ${userNames
              .slice(0, userNames.length - 1)
              .join(', ')} and ${userNames[
              userNames.length - 1
            ].toString()} goodnight!`
          : `${author.toString()} wishes ${userNames[0].toString()} goodnight!`;
    }
    channel.send(
      baseEmbed().setDescription(message).setImage(kaguya_sleep_gif),
    );
  },
});

export default goodnight;
<file_sep>import type { Client } from 'discord.js';

type IActivityUpdater = (client: Client) => void;

export function setGuildCount(client: Client) {
  const guildCount = client.guilds.cache.size;
  client.user
    ?.setActivity({
      name: `amogus in ${guildCount} ${
        guildCount === 1 ? 'server' : 'servers'
      }`,
      type: 'PLAYING',
    })
    .catch((err) => console.error(err));
}

export function setHelp(client: Client) {
  client.user
    ?.setActivity({
      name: `ck;help`,
      type: 'PLAYING',
    })
    .catch((err) => console.error(err));
}

export function setChikatto(client: Client) {
  client.user
    ?.setActivity({
      name: `<NAME>`,
      type: 'LISTENING',
    })
    .catch((err) => console.error(err));
}

export function setUserCount(client: Client) {
  const userCount = client.guilds.cache.reduce((acc, guild) => {
    return acc + guild.memberCount;
  }, 0);
  client.user
    ?.setActivity({
      name: `with ${userCount} ${userCount === 1 ? 'user' : 'users'}`,
      type: 'PLAYING',
    })
    .catch((err) => console.error(err));
}

export const activityUpdaters: IActivityUpdater[] = [
  setGuildCount,
  setHelp,
  setChikatto,
  setUserCount,
];
<file_sep>import { CmdCategory } from '@prisma/client';
import { balloonGame } from '../../games/balloon';
import { checkAndBlock } from '../../games/utils/manageState';
import { lightErrorEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';

const balloon = new Command({
  name: 'balloon',
  aliases: ['bl'],
  args: [],
  category: CmdCategory.GAMES,
  description: 'Chika hands you a balloon and you must pump it.',
  async execute(message) {
    checkAndBlock(balloonGame, message).then(
      () => balloonGame.pregame(message),
      (err) => message.channel.send(lightErrorEmbed(err)),
    );
  },
});

export default balloon;
<file_sep>import type { Redis } from 'ioredis';

export async function pubGuildCount(redis: Redis, count: number) {
  try {
    return redis.publish('guild-count-update', count.toString());
  } catch (err) {
    return console.error(err);
  }
}

export async function updateGuildList(redis: Redis, ids: string[]) {
  try {
    // we'll use the 'inGuilds' key
    return redis.set('inGuilds', JSON.stringify(ids));
  } catch (err) {
    return console.error(err);
  }
}
<file_sep>import { CmdCategory } from '@prisma/client';
import { stripIndents } from 'common-tags';
import ms from 'ms';
import { userProvider } from '../../data/providers/userProvider';
import { getCooldown, setCooldown } from '../../lib/cooldownManager';
import { endOfToday } from '../../lib/time';
import { ribbon_emoji } from '../../shared/assets';
import { baseEmbed, lightErrorEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';

const daily = new Command({
  name: 'daily',
  category: CmdCategory.CURRENCY,
  args: [],
  description: 'Collect your daily dose of ribbons.',

  async execute(message) {
    const { author, channel } = message;

    const pttl = await getCooldown(author.id, this.name);

    if (pttl) {
      channel.send(
        lightErrorEmbed(
          stripIndents`You've already collected today's ribbons!
          Please wait **${ms(pttl)}** before collecting again.`,
        ),
      );
      return;
    }

    const toAward = Math.floor(Math.random() * 200 + 100);
    channel.send(
      baseEmbed().setDescription(
        `**+ ${toAward}** ribbons ${ribbon_emoji} for **${author.username}**!`,
      ),
    );

    const nowStamp = Date.now();
    const cooldown = endOfToday() - nowStamp;

    setCooldown(author.id, this.name, cooldown);
    userProvider.incrRibbons(author, toAward);
  },
});

export default daily;
<file_sep>import he from 'he';

export const parseHtml = (s: string) =>
  he.decode(s.replace(/(<([^>]+)>)/gi, ''));

export const truncate = (s: string, max: number, byWord = false) => {
  if (byWord) {
    const words = s.split(' ');
    if (words.length < max) return s;
    return `${words.slice(0, max).join(' ')} ...`;
  }
  if (s.length <= max) return s;
  return `${s.substring(0, max)} ...`;
};

export const wrapText = (s: string) =>
  s.replace(/(?![^\n]{1,40}$)([^\n]{1,40})\s/g, '$1\n');

export const groupNum = new Intl.NumberFormat('en-US', { useGrouping: true });

export const withAnd = (words: string[]) => {
  if (words.length === 1) return words[0];
  if (words.length === 2) return `${words[0]} and ${words[1]}`;
  return `${words
    .slice(0, words.length - 1)
    .map((word) => `${word},`)
    .join(' ')} and ${words[words.length - 1]}`;
};
<file_sep>import { CmdCategory } from '@prisma/client';
import { lightErrorEmbed, withAuthorEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';
import { sendMusicOnlyInGuild, trackLinkAndDuration } from './utils/embeds';

const resume = new Command({
  name: 'resume',
  args: [],
  category: CmdCategory.MUSIC,
  description: 'Resume playback.',

  async execute(message) {
    const { client, channel, guild, author } = message;
    if (!guild) {
      sendMusicOnlyInGuild(channel);
      return;
    }
    const audioUtils = client.cache.audioUtils.get(guild.id);
    if (!audioUtils?.dispatcher.paused) {
      channel.send(lightErrorEmbed('There is nothing to resume...'));
      return;
    }

    audioUtils.dispatcher.resume();
    const { title, url, duration } = audioUtils.nowPlaying!;
    channel.send(
      withAuthorEmbed(author)
        .setTitle(`:arrow_forward: Resumed`)
        .setDescription(trackLinkAndDuration(title, url, duration)),
    );
  },
});

export default resume;
<file_sep>import type { Client, ClientEvents } from 'discord.js';

interface Event {
  name: keyof ClientEvents;
  once: boolean;
  listener: (client: Client, ...args: any[]) => Promise<void>;
}

export type { Event };
<file_sep>import { CmdCategory } from '@prisma/client';
import { redisQueue } from '../../data/redisClient';
import { lightErrorEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';
import { sendMusicOnlyInGuild, sendRepeat } from './utils/embeds';

const repeat = new Command({
  name: 'repeat',
  aliases: ['rp'],
  category: CmdCategory.MUSIC,
  description: 'Repeats the current track once.',
  args: [],

  async execute(message) {
    const { client, channel, guild, author } = message;
    if (!guild) {
      sendMusicOnlyInGuild(channel);
      return;
    }
    const audioUtils = client.cache.audioUtils.get(guild.id);
    if (!audioUtils) {
      channel.send(lightErrorEmbed('Nothing is playing now!'));
      return;
    }

    sendRepeat(channel, { videoData: audioUtils.nowPlaying, author });
    redisQueue.lpush(guild.id, JSON.stringify(audioUtils.nowPlaying));
  },
});

export default repeat;
<file_sep>import { CmdCategory } from '@prisma/client';
import { lightErrorEmbed, sendNotInGuild } from '../../shared/embeds';
import { Command } from '../../types/command';
import { setCooldown } from '../../lib/cooldownManager';
import { sendSearchResults } from './utils/embeds';
import { createResultSelectListener } from './utils/listener';
import { searchVideo } from './utils/youtube';

const search = new Command({
  name: 'search',
  description: 'Search for a track on YouTube',
  args: [{ name: 'search_string', multi: true }],
  category: CmdCategory.MUSIC,
  channelCooldown: 15000,

  async execute(message, args) {
    const { channel, client, guild } = message;
    if (!guild) {
      sendNotInGuild(channel);
      return;
    }

    setCooldown(channel.id, this.name, this.channelCooldown!);

    const results = await searchVideo(args.join(' '));
    if (!results) {
      channel.send(lightErrorEmbed('Your search received no results.'));
      return;
    }

    sendSearchResults(channel, results);

    const resultSelectListener = createResultSelectListener(results, {
      channelId: channel.id,
      guildId: guild.id,
    });
    const timeoutCallback = () => {
      client.removeListener('message', resultSelectListener);
    };
    client.on('message', resultSelectListener);
    client.setTimeout(timeoutCallback, this.channelCooldown!);
  },
});

export default search;
<file_sep>import Discord from 'discord.js';
import { setIntervals } from './init/cron';
import { genFullHelpEmbed } from './init/fullHelpEmbed';
import { initialClientCache } from './init/initialClientCache';
import { loadCommands } from './init/loadCommands';
import { loadEventListeners } from './init/loadEventListeners';
import { seedCommands } from './init/seedCommands';
import { setHelp } from './lib/activityUpdaters';

const main = () => {
  // instantiate the client
  const client = new Discord.Client();
  // login to the gateway, and set our status
  client.login(process.env.APP_TOKEN).then(() => setHelp(client));
  // load commands
  client.commands = loadCommands();
  // seed commands to our database
  seedCommands(client.commands);
  // generates help message based on commands
  client.commandsHelp = genFullHelpEmbed(client.commands);
  // creates a "cache" object
  client.cache = initialClientCache;
  // apply event listeners
  loadEventListeners(client);
  client.setMaxListeners(2048);
  // "cron-like" jobs
  setIntervals(client);
};

process.on('unhandledRejection', (err) => {
  console.error(`!!!!! An error has made it to index.ts !!!!!\n`, err);
  throw err;
});

main();
<file_sep>import type { Client } from 'discord.js';
import fs from 'fs';
import path from 'path';
import { Event } from '../types/event';

export const loadEventListeners = (client: Client) => {
  const eventFiles = fs
    .readdirSync(path.join(__dirname, '..', 'events'))
    .filter((filename) => filename.endsWith('.js'));
  eventFiles.forEach((file) => {
    // eslint-disable-next-line @typescript-eslint/no-var-requires
    const event: Event = require(`../events/${file}`).default;
    if (event.once) {
      client.once(event.name, async (...args) =>
        event.listener(client, ...args),
      );
    } else {
      client.on(event.name, async (...args) => event.listener(client, ...args));
    }
  });
};
<file_sep>import { commaListsAnd } from 'common-tags';
import { redis } from '../data/redisClient';
import { Event } from '../types/event';
import { updateGuildList } from './lib/guild';

const ready: Event = {
  name: 'ready',
  once: true,
  async listener(client) {
    console.log('Chika bot is ready.');
    // set guilds in redis
    const { cache } = client.guilds;
    const ids = cache.map((guild) => guild.id);
    await updateGuildList(redis, ids);
    console.log(commaListsAnd`We are in ${cache.map((guild) => guild.name)}.`);
  },
};

export default ready;
<file_sep>import type { Snowflake } from 'discord.js';
import { forCooldown, redis } from '../data/redisClient';

export const setCooldown = (id: Snowflake, command: string, ms: number) =>
  redis.set(forCooldown(`${id}:${command}`), command, 'px', ms);

export const getCooldown = async (id: Snowflake, command: string) => {
  // returns in milliseconds
  const ttl = await redis.pttl(forCooldown(`${id}:${command}`));
  if (ttl <= 0) {
    return 0;
  }
  return ttl;
};
<file_sep>NODE_ENV=
APP_TOKEN=
BOT_USER_ID=
REDISCLOUD_URL=
DATABASE_URL=
HUGGING_FACE_CHIKA=
HUGGING_FACE_CHIKA_KEY=
HUGGING_FACE_KAGUYA=
HUGGING_FACE_KAGUYA_KEY=
ANILIST_SCHEMA=<file_sep>import type { Client, StreamDispatcher, VoiceConnection } from 'discord.js';
import { v4 } from 'uuid';
import ytdl from 'ytdl-core';
import ytpl from 'ytpl';
import ytsr, { Video } from 'ytsr';
import { forNowPlaying, redis } from '../../../data/redisClient';
import { CriticalError } from '../../../shared/errors';
import { GenericChannel } from '../../../types/command';
import { AudioUtils, QueueItem } from '../../../types/queue';
import { sendCannotPlay, sendNowPlaying } from './embeds';
import { secToString } from './helpers';
import type { createFinishListener } from './listener';

const YOUTUBE_URL_RE = /^(https?:\/\/)?((www\.)?youtube\.com|youtu\.?be)\/.+$/;

export const searchVideo = async (
  title: string,
  maxResults = 10,
): Promise<QueueItem[] | null> => {
  const vFilter = (await ytsr.getFilters(title)).get('Type')!.get('Video')!;
  const videos = (await ytsr(vFilter.url!, { limit: maxResults }))
    .items as Video[];
  if (videos.length === 0) return null;
  return videos.map(
    (video): QueueItem => ({
      id: v4(),
      title: video.title,
      duration: video.duration!,
      thumbnailURL: video.bestThumbnail.url!,
      url: video.url,
    }),
  );
};

export const playFromYt = async (
  connection: VoiceConnection,
  url: string,
): Promise<StreamDispatcher> => {
  try {
    const dispatcher = await ytdl.getInfo(url).then((info) => {
      return connection.play(
        ytdl.downloadFromInfo(info, {
          filter: 'audioonly',
          quality: 'highestaudio',
          highWaterMark: 1 << 25,
        }),
      );
    });
    return dispatcher;
  } catch (err) {
    console.error(err);
    if (err.statusCode >= 400) {
      throw new Error('YouTube has blocked us, or something bad has happened.');
    } else {
      throw err;
    }
  }
};

export const validateArgs = async (
  args: string[],
): Promise<QueueItem | null> => {
  if (YOUTUBE_URL_RE.test(args[0])) {
    const [url] = args;
    if (!ytdl.validateURL(url)) return null;
    let res: ytdl.videoInfo;
    try {
      res = await ytdl.getBasicInfo(url);
    } catch (err) {
      return null;
    }
    const { videoDetails } = res;
    if (!videoDetails) return null;
    return {
      id: v4(),
      title: videoDetails.title,
      duration: secToString(parseInt(videoDetails.lengthSeconds, 10)),
      url: videoDetails.video_url,
      thumbnailURL: videoDetails.thumbnails[0].url,
    };
  }

  const res = await searchVideo(args.join(' '), 1);
  if (!res) return null;
  return { ...res[0] };
};

interface PlaylistMetadata {
  title: string;
  thumbnailURL: string;
  url: string;
}

export const parsePlaylist = (
  res: ytpl.Result,
): [PlaylistMetadata, QueueItem[]] => {
  const metadata: PlaylistMetadata = {
    title: res.title,
    url: res.url,
    thumbnailURL: res.bestThumbnail.url!,
  };
  const items = res.items.map(
    (item): QueueItem => ({
      id: v4(),
      url: item.shortUrl,
      title: item.title,
      thumbnailURL: item.bestThumbnail.url!,
      duration: item.duration!,
    }),
  );
  return [metadata, items];
};

interface playThisParams {
  client: Client;
  channel: GenericChannel;
  guildId: string;
  onFinish: ReturnType<typeof createFinishListener>;
}

export const playThis = async (
  connection: VoiceConnection,
  videoData: QueueItem,
  { client, channel, guildId, onFinish }: playThisParams,
): Promise<void> => {
  try {
    const dispatcher = await playFromYt(connection, videoData.url);
    redis.set(forNowPlaying(guildId), JSON.stringify(videoData));
    sendNowPlaying(channel, videoData);
    const newAudioUtils: AudioUtils = {
      connection,
      dispatcher,
      nowPlaying: videoData,
    };
    dispatcher.on('finish', onFinish);
    client.cache.audioUtils.set(guildId, newAudioUtils);
  } catch (err) {
    sendCannotPlay(channel, videoData.title, videoData.url);
    connection.disconnect();
    if (err instanceof CriticalError) throw err;
  }
};
<file_sep>import { CmdCategory } from '@prisma/client';
import { guildProvider } from '../../data/providers/guildProvider';
import { isAdmin } from '../../lib/validateMessages';
import { baseEmbed, sendNotInGuild } from '../../shared/embeds';
import { Command } from '../../types/command';

const prefix = new Command({
  name: 'set-prefix',
  args: [{ name: 'new_prefix' }],
  category: CmdCategory.UTILITY,
  description: "Set a new prefix for Chika. You'll need to be an admin.",

  async execute(message, args) {
    const { channel, guild } = message;
    if (!guild) {
      sendNotInGuild(channel);
      return;
    }
    if (!isAdmin(message)) return;
    const [newPrefix] = args;
    guildProvider.setPrefix(guild.id, newPrefix);

    channel.send(
      baseEmbed().setDescription(`Chika's prefix is now **${newPrefix}**`),
    );
  },
});

export default prefix;
<file_sep>import { TextChannel } from 'discord.js';
import { mock } from 'jest-mock-extended';
import * as Embeds from '../shared/embeds';
import { Command } from '../types/command';
import { validateArgsCount } from './validateArgsCount';
jest.mock('discord.js');
jest.mock('../shared/embeds');

describe('#validateArgsCount', () => {
  const mockCommand = mock<Command>();
  const mockChannel = mock<TextChannel>();

  const embedErrorSpy = jest.spyOn(Embeds, 'lightErrorEmbed');
  const channelSendSpy = jest.spyOn(mockChannel, 'send');

  beforeEach(() => {
    embedErrorSpy.mockClear();
    channelSendSpy.mockClear();
  });

  describe('for command with no params', () => {
    beforeEach(() => {
      mockCommand.args = [];
    });
    it('should return null if any args submitted', () => {
      expect(validateArgsCount(mockCommand, ['1', '2'], mockChannel)).toBe(
        null,
      );
      expect(validateArgsCount(mockCommand, ['1'], mockChannel)).toBe(null);
      expect(embedErrorSpy).toBeCalledTimes(2);
      expect(channelSendSpy).toBeCalledTimes(2);
    });
    it('should return [] if no args submitted', () => {
      const args: string[] = [];
      expect(validateArgsCount(mockCommand, args, mockChannel)).toBe(args);
      expect(embedErrorSpy).toBeCalledTimes(0);
      expect(channelSendSpy).toBeCalledTimes(0);
    });
  });

  describe('for command with N mono-required params', () => {
    beforeEach(() => {
      mockCommand.args = [{ name: 'arg1' }, { name: 'arg2' }];
    });
    it('should return null if args < N', () => {
      expect(validateArgsCount(mockCommand, ['1'], mockChannel)).toBe(null);
      expect(embedErrorSpy).toBeCalledTimes(1);
      expect(channelSendSpy).toBeCalledTimes(1);
    });
    it('should return null if args > N', () => {
      expect(validateArgsCount(mockCommand, ['1', '2', '3'], mockChannel)).toBe(
        null,
      );
      expect(embedErrorSpy).toBeCalledTimes(1);
      expect(channelSendSpy).toBeCalledTimes(1);
    });
    it('should return args if args = N', () => {
      const args = ['1', '2'];
      expect(validateArgsCount(mockCommand, args, mockChannel)).toBe(args);
    });
    expect(embedErrorSpy).toBeCalledTimes(0);
    expect(channelSendSpy).toBeCalledTimes(0);
  });

  describe('for command with N mono-optional params only', () => {
    beforeEach(() => {
      mockCommand.args = [
        { name: 'arg1', optional: true },
        { name: 'arg2', optional: true },
      ];
    });
    it('should return args if no args submitted', () => {
      const args: string[] = [];
      expect(validateArgsCount(mockCommand, args, mockChannel)).toBe(args);
      expect(embedErrorSpy).toBeCalledTimes(0);
      expect(channelSendSpy).toBeCalledTimes(0);
    });
    it('should return args if args < N', () => {
      const args = ['1'];
      expect(validateArgsCount(mockCommand, args, mockChannel)).toBe(args);
      expect(embedErrorSpy).toBeCalledTimes(0);
      expect(channelSendSpy).toBeCalledTimes(0);
    });
    it('should return args if args = N', () => {
      const args = ['1', '2'];
      expect(validateArgsCount(mockCommand, args, mockChannel)).toBe(args);
      expect(embedErrorSpy).toBeCalledTimes(0);
      expect(channelSendSpy).toBeCalledTimes(0);
    });
    it('should return null if args > N', () => {
      const args = ['1', '2', '3'];
      expect(validateArgsCount(mockCommand, args, mockChannel)).toBe(null);
      expect(embedErrorSpy).toBeCalledTimes(1);
      expect(channelSendSpy).toBeCalledTimes(1);
    });
  });

  describe('for command with N mono-required and M mono-optional params', () => {
    beforeEach(() => {
      mockCommand.args = [
        { name: 'arg1' },
        { name: 'arg2' },
        { name: 'arg3', optional: true },
        { name: 'arg4', optional: true },
      ];
    });
    it('should return null if args < N', () => {
      expect(validateArgsCount(mockCommand, ['1'], mockChannel)).toBe(null);
      expect(embedErrorSpy).toBeCalledTimes(1);
      expect(channelSendSpy).toBeCalledTimes(1);
    });
    it('should return args if args = N', () => {
      const args = ['1', '2'];
      expect(validateArgsCount(mockCommand, args, mockChannel)).toBe(args);
      expect(embedErrorSpy).toBeCalledTimes(0);
      expect(channelSendSpy).toBeCalledTimes(0);
    });
    it('should return args if N < args <= N + M', () => {
      const args3 = ['1', '2', '3'];
      expect(validateArgsCount(mockCommand, args3, mockChannel)).toBe(args3);
      expect(embedErrorSpy).toBeCalledTimes(0);
      expect(channelSendSpy).toBeCalledTimes(0);

      const args4 = ['1', '2', '3', '4'];
      expect(validateArgsCount(mockCommand, args4, mockChannel)).toBe(args4);
      expect(embedErrorSpy).toBeCalledTimes(0);
      expect(channelSendSpy).toBeCalledTimes(0);
    });
    it('should return null if args > N', () => {
      expect(
        validateArgsCount(mockCommand, ['1', '2', '3', '4', '5'], mockChannel),
      ).toBe(null);
      expect(embedErrorSpy).toBeCalledTimes(1);
      expect(channelSendSpy).toBeCalledTimes(1);
    });
  });

  describe('for command with N multi-required params', () => {
    beforeEach(() => {
      mockCommand.args = [
        { name: 'arg1', multi: true },
        { name: 'arg2', multi: true },
      ];
    });
    it('should return null if args < N', () => {
      expect(validateArgsCount(mockCommand, [], mockChannel)).toBe(null);
      expect(validateArgsCount(mockCommand, ['1'], mockChannel)).toBe(null);
      expect(embedErrorSpy).toBeCalledTimes(2);
      expect(channelSendSpy).toBeCalledTimes(2);
    });
    it('should return args if args >= N', () => {
      let args = ['1', '2'];
      expect(validateArgsCount(mockCommand, args, mockChannel)).toBe(args);
      args = ['1', '2', '3'];
      expect(validateArgsCount(mockCommand, args, mockChannel)).toBe(args);
      expect(embedErrorSpy).toBeCalledTimes(0);
      expect(channelSendSpy).toBeCalledTimes(0);
    });
  });

  describe('for command with N multi-optional params', () => {
    beforeEach(() => {
      mockCommand.args = [
        { name: 'arg1', multi: true, optional: true },
        { name: 'arg2', multi: true, optional: true },
      ];
    });
    it('should return args if no args submitted', () => {
      const args: string[] = [];
      expect(validateArgsCount(mockCommand, args, mockChannel)).toBe(args);
      expect(embedErrorSpy).toBeCalledTimes(0);
      expect(channelSendSpy).toBeCalledTimes(0);
    });
    it('should return args if args < N', () => {
      const args = ['1'];
      expect(validateArgsCount(mockCommand, args, mockChannel)).toBe(args);
      expect(embedErrorSpy).toBeCalledTimes(0);
      expect(channelSendSpy).toBeCalledTimes(0);
    });
    it('should return args if args = N', () => {
      const args = ['1', '2'];
      expect(validateArgsCount(mockCommand, args, mockChannel)).toBe(args);
      expect(embedErrorSpy).toBeCalledTimes(0);
      expect(channelSendSpy).toBeCalledTimes(0);
    });
    it('should return args if args > N', () => {
      const args = ['1', '2', '3', '4'];
      expect(validateArgsCount(mockCommand, args, mockChannel)).toBe(args);
      expect(embedErrorSpy).toBeCalledTimes(0);
      expect(channelSendSpy).toBeCalledTimes(0);
    });
  });

  describe('for command with N multi-required and M multi-optional params', () => {
    beforeEach(() => {
      mockCommand.args = [
        { name: 'arg1', multi: true },
        { name: 'arg2', multi: true },
        { name: 'arg3', multi: true, optional: true },
        { name: 'arg4', multi: true, optional: true },
      ];
    });
    it('should return null if no args submitted', () => {
      const args: string[] = [];
      expect(validateArgsCount(mockCommand, args, mockChannel)).toBe(null);
      expect(embedErrorSpy).toBeCalledTimes(1);
      expect(channelSendSpy).toBeCalledTimes(1);
    });
    it('should return null if args < N', () => {
      const args = ['1'];
      expect(validateArgsCount(mockCommand, args, mockChannel)).toBe(null);
      expect(embedErrorSpy).toBeCalledTimes(1);
      expect(channelSendSpy).toBeCalledTimes(1);
    });
    it('should return args if args = N', () => {
      const args = ['1', '2'];
      expect(validateArgsCount(mockCommand, args, mockChannel)).toBe(args);
      expect(embedErrorSpy).toBeCalledTimes(0);
      expect(channelSendSpy).toBeCalledTimes(0);
    });
    it('should return args if N < args < N + M', () => {
      const args = ['1', '2', '3'];
      expect(validateArgsCount(mockCommand, args, mockChannel)).toBe(args);
      expect(embedErrorSpy).toBeCalledTimes(0);
      expect(channelSendSpy).toBeCalledTimes(0);
    });
    it('should return args if args = N + M', () => {
      const args = ['1', '2', '3', '4'];
      expect(validateArgsCount(mockCommand, args, mockChannel)).toBe(args);
      expect(embedErrorSpy).toBeCalledTimes(0);
      expect(channelSendSpy).toBeCalledTimes(0);
    });
    it('should return args if args > N + M', () => {
      const args = ['1', '2', '3', '4', '5'];
      expect(validateArgsCount(mockCommand, args, mockChannel)).toBe(args);
      expect(embedErrorSpy).toBeCalledTimes(0);
      expect(channelSendSpy).toBeCalledTimes(0);
    });
  });

  describe('for command with >=1 mono-required/mono-optional and 0 multi', () => {
    it('should return null if 0 args given', () => {
      mockCommand.args = [{ name: 'arg1' }, { name: 'arg2', optional: true }];
      expect(validateArgsCount(mockCommand, [], mockChannel)).toBe(null);
      expect(embedErrorSpy).toBeCalledTimes(1);
      expect(channelSendSpy).toBeCalledTimes(1);
    });
    it('should return null if too many args are given', () => {
      mockCommand.args = [{ name: 'arg3', optional: true }];
      expect(
        validateArgsCount(mockCommand, ['1', '2', '3', '4'], mockChannel),
      ).toBe(null);
      expect(embedErrorSpy).toBeCalledTimes(1);
      expect(channelSendSpy).toBeCalledTimes(1);
    });
  });

  describe('for commands with no optionals params', () => {
    describe('and exactly 1 mono-required param', () => {
      it('should return null if 0 args given', () => {
        mockCommand.args = [{ name: 'arg1' }];
        expect(validateArgsCount(mockCommand, [], mockChannel)).toBe(null);
        expect(embedErrorSpy).toBeCalledTimes(1);
        expect(channelSendSpy).toBeCalledTimes(1);
      });
    });
  });
});
<file_sep>const padTimeZero = (chunk: number): string =>
  `${chunk <= 9 ? `0${chunk}` : chunk}`;

export const secToString = (sec: number): string => {
  if (sec < 60) return `00:${padTimeZero(sec)}`;

  if (sec > 60 && sec < 3600)
    return `${padTimeZero(Math.floor(sec / 60))}:${padTimeZero(sec % 60)}`;

  const subHour = sec % 3600;
  const ss = subHour % 60;
  const mm = (subHour - ss) / 60;
  const hh = Math.floor(sec / 3600);
  return `${padTimeZero(hh)}:${padTimeZero(mm)}:${padTimeZero(ss)}`;
};

export const stringToSec = (min: string): number => {
  const [hh, mm, ss] = min.split(':');
  if (ss) {
    return 3600 * parseInt(hh, 10) + 60 * parseInt(mm, 10) + parseInt(ss, 10);
  }
  if (mm) {
    return 60 * parseInt(hh, 10) + parseInt(mm, 10);
  }
  return parseInt(hh, 10);
};
<file_sep>import { CmdCategory } from '@prisma/client';
import { balloonProvider } from '../../data/providers/balloonProvider';
import {
  baseEmbed,
  lightErrorEmbed,
  sendNotInGuild,
} from '../../shared/embeds';
import { Command } from '../../types/command';

const balloonMax = new Command({
  name: 'balloon-max',
  aliases: ['bl-max'],
  category: CmdCategory.GAMES,
  description:
    "Check or set the upper bound for balloons' volumes in this server.",
  args: [{ name: 'new_max', optional: true }],
  async execute(message, args) {
    const { channel, guild } = message;
    if (!guild) {
      sendNotInGuild(channel);
      return;
    }
    const [_newMax] = args;
    if (!_newMax) {
      const currMax = await balloonProvider.getMax(guild.id);
      channel.send(
        baseEmbed().setDescription(`Current max volume: **${currMax}**`),
      );
      return;
    }
    const newMax = parseInt(_newMax, 10);
    if (Number.isNaN(newMax)) {
      channel.send(lightErrorEmbed(`Please give me a valid number!`));
      return;
    }
    const currMin = await balloonProvider.getMin(guild.id);
    if (newMax < currMin) {
      channel.send(
        lightErrorEmbed(
          `The current min is **${currMin}**.
          
          Please set a max below that, or lower the min.`,
        ),
      );
      return;
    }
    if (newMax <= 0) {
      channel.send(
        lightErrorEmbed('Volume is a *non-negative scalar*  ლ(´ڡ`ლ)'),
      );
      return;
    }
    await balloonProvider.setMax(newMax, guild.id);
    channel.send(
      baseEmbed().setDescription(
        `The maximum balloon volume has been set to **${newMax}**!
        This will apply on the next game.`,
      ),
    );
  },
});

export default balloonMax;
<file_sep>export const DEFAULT_PREFIX = 'ck;';
export const DEFAULT_PREFIX_RE = /^ck;/i;

export const GLOBAL_RIBBONS = 'global';
<file_sep>import { CmdCategory } from '.prisma/client';
import { Command } from '../../types/command';
import { dashboardLink } from './embeds/dashboardLinkEmbed';

const dashboard = new Command({
  name: 'dashboard',
  aliases: ['dbd'],
  description: 'Get a link to the dashboard app',
  args: [],
  category: CmdCategory.UTILITY,
  async execute(ctx) {
    const { channel } = ctx;
    channel.send(dashboardLink);
  },
});

export default dashboard;
<file_sep>import { shuffle } from './shuffle';

describe('#shuffle', () => {
  const testArr = [1, 2, 3, 4, 5];
  it('should not mutate the original array', () => {
    shuffle(testArr);
    expect(testArr).toEqual([1, 2, 3, 4, 5]);
  });
  it('should shuffle lol', () => {
    expect(shuffle(testArr)).not.toEqual(testArr);
  });
});
<file_sep>/*
  Warnings:

  - Made the column `commandId` on table `Arg` required. This step will fail if there are existing NULL values in that column.

*/
-- AlterTable
ALTER TABLE "Arg" ALTER COLUMN "commandId" SET NOT NULL;

-- AlterTable
ALTER TABLE "Command" ADD COLUMN     "aliases" TEXT[];

-- AlterTable
ALTER TABLE "Shiritori" ADD COLUMN     "minLen" INTEGER;
<file_sep>> ### Chika joins Discord
>
> _Hi Chika, thanks for joining us here. Would you like to introduce yourself?_
> <br/><br/>
> **<NAME>**: Oh hi!! didn't see you there. I was playing Amogus with Kei. But thanks for having me here.
> <br/><br/>
> _I see, so what can you do as a Discord bot?_
> <br/><br/>
> **<NAME>**: You know, I can't compete with the likes of MEE6 or Mudae, but I'm trying my best anyway. You can refer to the banner below for a list of commands.
> <br/><br/>
> _That's great that you're trying your best! I heard you have an IQ of 3?_
> <br/><br/>
> **<NAME>**: I think so. Is that bad?
> <br/><br/>
> _No, it's great._
> <br/><br/>
> **<NAME>**: Haha okay. Anyway, I'll bring the entire Shuchi'in Tabletop Game Club to Discord some day. Please stay tuned!

> ### Introduction
>
> Chika doesn't seem to care for moderation. She is all about dat entertainment value.
> <br/><br/>
> Stream music with Chika, or enjoy a few rounds of Shiritori and Balloon with your friends. More games are being developed!
> <br/><br/>
> You can also have a chat with Chika using `ck;chika`. This costs _ribbons_, the de-facto currency for Chika bot. Please note that this feature is completely experimental now.

> ### Usage
>
> Chika's default prefix is `ck;` and this is customizable per-server. Here are the commands as of 23 Jun 2021.
> <br/><br/>
> ![](https://i.imgur.com/pqNfPMx.png)
> <br/><br/>
> We hope that you'll have lots of fun with Chika bot!
<file_sep>import { PrismaClient } from '.prisma/client';
import { Snowflake } from 'discord.js';
import { Redis } from 'ioredis';
import { DEFAULT_PREFIX } from '../../shared/constants';
import { prisma } from '../prismaClient';
import { forBlockedCommands, forPrefix, redis } from '../redisClient';

export class GuildProvider {
  constructor(
    private readonly prisma: PrismaClient,
    private readonly redis: Redis,
  ) {}

  async getPrefix(guildId: Snowflake) {
    const ping = await this.redis.get(forPrefix(guildId));
    if (ping) {
      this.redis.expire(forPrefix(guildId), 60);
      return ping;
    }
    return this.prisma.guild
      .findUnique({
        where: { guildId },
        select: { prefix: true },
      })
      .then((res) => {
        let prefix = res?.prefix;
        if (!prefix) prefix = DEFAULT_PREFIX;
        this.redis.set(forPrefix(guildId), prefix, 'ex', 60);
        return prefix;
      });
  }

  async setPrefix(guildId: Snowflake, prefix: string) {
    redis.set(forPrefix(guildId), prefix, 'ex', 60);

    await this.prisma.guild.upsert({
      create: { guildId, prefix },
      update: { prefix },
      where: { guildId },
    });
  }

  getBlockedCommands(guildId: Snowflake): Promise<string[]> {
    return this.redis.smembers(forBlockedCommands(guildId));
  }

  async isBlocked(guildId: Snowflake, name: string): Promise<boolean> {
    const res = await this.redis.sismember(forBlockedCommands(guildId), name);
    return Boolean(res);
  }

  blockCommand(guildId: Snowflake, name: string): Promise<number> {
    return redis.sadd(forBlockedCommands(guildId), name);
  }

  unblockCommand(guildId: Snowflake, name: string): Promise<number> {
    return redis.srem(forBlockedCommands(guildId), name);
  }
}

export const guildProvider = new GuildProvider(prisma, redis);
<file_sep>import { setCooldown, getCooldown } from './cooldownManager';
import { forCooldown, redis } from '../data/redisClient';

afterAll(() => {
  return redis.quit();
});

describe('cooldownManager', () => {
  const setSpy = jest.spyOn(redis, 'set');
  const pttlSpy = jest.spyOn(redis, 'pttl');

  beforeEach(() => {
    setSpy.mockReset();
    pttlSpy.mockReset();
  });

  describe('#setCooldown', () => {
    it('calls set once', () => {
      setCooldown('id', 'command', 10);
      expect(setSpy).toBeCalledTimes(1);
    });
    it('calls set with correct params', () => {
      setCooldown('id', 'command', 10);
      expect(setSpy).toBeCalledWith(
        forCooldown('id:command'),
        'command',
        'px',
        10,
      );
    });
  });

  describe('#getCooldown', () => {
    it('calls get once', () => {
      getCooldown('id', 'command');
      expect(pttlSpy).toBeCalledTimes(1);
    });
    it('calls set with correct params', () => {
      getCooldown('id', 'command');
      expect(pttlSpy).toBeCalledWith(forCooldown('id:command'));
    });
    it('returns 0 if key already expired or has no ttl set (-1 or -2)', () => {
      pttlSpy.mockResolvedValueOnce(-1);
      return getCooldown('id', 'command').then((res) => {
        expect(res).toBe(0);
      });
    });
    it('returns ttl if ttl is found', () => {
      pttlSpy.mockResolvedValueOnce(1000);
      return getCooldown('id', 'command').then((res) => {
        expect(res).toBe(1000);
      });
    });
  });
});
<file_sep>export const DEFAULT_MIN_BALLOON = 20;
export const DEFAULT_MAX_BALLOON = 500;
<file_sep>import { secToString, stringToSec } from './helpers';

describe('#stringToSec', () => {
  describe('given HH:MM:SS', () => {
    it('should convert correctly', () => {
      expect(stringToSec('05:20:30')).toEqual(19230);
    });
  });
  describe('given MM:SS', () => {
    it('should convert correctly', () => {
      expect(stringToSec('10:10')).toEqual(610);
    });
  });
  describe('given SS', () => {
    it('should convert correctly', () => {
      expect(stringToSec('47')).toEqual(47);
    });
  });
});

describe('#secToString', () => {
  describe('given less than a minute', () => {
    it('should convert correctly', () => {
      expect(secToString(50)).toEqual('00:50');
    });
  });
  describe('given more than a minute, less than an hour', () => {
    it('should convert correctly', () => {
      expect(secToString(320)).toEqual('05:20');
    });
  });
  describe('given more than an hour', () => {
    it('should convert correctly', () => {
      expect(secToString(19230)).toEqual('05:20:30');
    });
  });
});
<file_sep>export const EXIT_GAME_RE = /^;stopgame$/i;
export const EXIT_GAME = ';stopgame';
<file_sep>import type { Collection, User } from 'discord.js';
import { GameState } from '../../../types/gameState';

interface BalloonState extends GameState {
  players: Collection<string, User>;
  tolerance: number;
  currentVolume: number;
}

export type { BalloonState };
<file_sep>import {
  cryingEmbed,
  lightErrorEmbed,
  peekingEmbed,
} from '../../shared/embeds';
import { GenericChannel } from '../../types/command';

export const sendNoGameSelectedError = async (channel: GenericChannel) => {
  channel.send(lightErrorEmbed('Tell me which game you wanna play, yo.'));
};

export const sendTaggedSelfError = async (channel: GenericChannel) => {
  channel.send(
    peekingEmbed().setTitle('...').setDescription(`Do you have no friends.`),
  );
};

export const sendUnknownGameError = async (
  gameName: string,
  channel: GenericChannel,
) => {
  channel.send(
    lightErrorEmbed(`Sorry...I don't know how to play **${gameName}**.`),
  );
  // TODO return a list of playable games
};

export const sendGameCrashedError = async (channel: GenericChannel) => {
  channel.send(
    cryingEmbed()
      .setTitle('what the heck')
      .setDescription('The game crashed for some reason.'),
  );
};

export const sendInGame = async (channel: GenericChannel) =>
  channel.send(
    lightErrorEmbed(
      'There is a game being played in this channel!\nPlease wait till the current game is finished.',
    ),
  );
<file_sep>import { CmdCategory } from '@prisma/client';
import { lightErrorEmbed, withAuthorEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';
import { sendMusicOnlyInGuild, toUrlString } from './utils/embeds';
import { createFinishListener } from './utils/listener';

const skip = new Command({
  name: 'skip',
  description: 'Skip the current track.',
  args: [],
  category: CmdCategory.MUSIC,

  async execute(message) {
    const { channel, guild, client, author } = message;
    if (!guild) {
      sendMusicOnlyInGuild(channel);
      return;
    }
    const audioUtils = client.cache.audioUtils.get(guild.id);
    if (!audioUtils) {
      channel.send(lightErrorEmbed('There is no track to skip.'));
      return;
    }
    channel.send(
      withAuthorEmbed(author).setDescription(
        `Skipping **${toUrlString(
          audioUtils.nowPlaying.title,
          audioUtils.nowPlaying.url,
          40,
        )}**`,
      ),
    );
    if (!audioUtils.dispatcher.paused) {
      audioUtils.dispatcher.end();
      return;
    }

    // a paused track cannot emit 'finish' for some reason
    // we'll do this manually
    createFinishListener(guild, {
      channel,
      client,
    })();
  },
});

export default skip;
<file_sep>import { lightErrorEmbed } from '../../../shared/embeds';
import { GenericChannel } from '../../../types/command';

export const sendNotFoundError = (s: string, channel: GenericChannel) =>
  channel.send(lightErrorEmbed(`I couldn't find any info for **${s}**.`));
<file_sep>import type { VoiceChannel, VoiceConnection } from 'discord.js';

export const tryToConnect = async (
  channel: VoiceChannel,
): Promise<VoiceConnection | null> =>
  channel
    .join()
    .then((conn) => conn)
    .catch(() => null);
<file_sep>import type { Message } from 'discord.js';
import { lightErrorEmbed } from '../../shared/embeds';
import { Game } from '../../types/game';
import { sendTaggedSelfError } from './embeds';

// mentions should only work for strictly 2 player games
export const validateMentions = (message: Message, game: Game) => {
  const { mentions, channel, author } = message;
  const taggedCount = mentions.users.size;
  if (!taggedCount) {
    return true;
  }
  const isTwoPlayer = game.minPlayerCount === 2 && game.maxPlayerCount === 2;
  if (!isTwoPlayer && taggedCount > 0) {
    channel.send(
      lightErrorEmbed(`You can only tag another player for 2-player games.`),
    );
    return false;
  }
  if (isTwoPlayer && taggedCount !== 1) {
    channel.send(
      lightErrorEmbed(
        `Please tag only one other user to play **${game.displayTitle}** with.`,
      ),
    );
    return false;
  }
  if (process.env.NODE_ENV !== 'development') {
    if (isTwoPlayer && mentions.users.first()!.id === author.id) {
      sendTaggedSelfError(channel);
      return false;
    }
  }
  return true;
};
<file_sep>declare namespace NodeJS {
  export interface ProcessEnv {
    NODE_ENV: string;
    APP_TOKEN: string;
    BOT_USER_ID: string;
    DATABASE_URL: string;
    REDISCLOUD_URL: string;
    HUGGING_FACE_CHIKA: string;
    HUGGING_FACE_KAGUYA: string;
    HUGGING_FACE_CHIKA_KEY: string;
    HUGGING_FACE_KAGUYA_KEY: string;
    ANILIST_SCHEMA: string;
  }
}
<file_sep>export const MAX_TAKE = 20;
<file_sep>import { CmdCategory } from '@prisma/client';
import { shiritoriGame } from '../../games/shiritori';
import { Command } from '../../types/command';

const shiritoriRules = new Command({
  name: 'shiritori-rules',
  aliases: ['sh-rules'],
  args: [],
  category: CmdCategory.GAMES,
  description: 'Check the rules for Shiritori.',

  async execute(message) {
    const { channel } = message;
    channel.send(shiritoriGame.rules);
  },
});

export default shiritoriRules;
<file_sep>import { CmdCategory } from '@prisma/client';
import { balloonProvider } from '../../data/providers/balloonProvider';
import {
  baseEmbed,
  lightErrorEmbed,
  sendNotInGuild,
} from '../../shared/embeds';
import { Command } from '../../types/command';

const balloonMin = new Command({
  name: 'balloon-min',
  aliases: ['bl-min'],
  category: CmdCategory.GAMES,
  description:
    "Check or set the lower bound for balloons' volumes in this server.",
  args: [{ name: 'new_min', optional: true }],

  async execute(message, args) {
    const { channel, guild } = message;
    if (!guild) {
      sendNotInGuild(channel);
      return;
    }
    const [_newMin] = args;
    if (!_newMin) {
      const currMin = await balloonProvider.getMin(guild.id);
      channel.send(
        baseEmbed().setDescription(`Current min volume: **${currMin}**`),
      );
      return;
    }
    const newMin = parseInt(_newMin, 10);
    if (Number.isNaN(newMin)) {
      channel.send(lightErrorEmbed(`Please give me a valid number!`));
      return;
    }
    const currMax = await balloonProvider.getMax(guild.id);
    if (newMin > currMax) {
      channel.send(
        lightErrorEmbed(
          `The current max is **${currMax}**.
          
          Please set a min below that, or raise the max.`,
        ),
      );
      return;
    }
    if (newMin <= 0) {
      channel.send(
        lightErrorEmbed('Volume is a *non-negative scalar*  ლ(´ڡ`ლ)'),
      );
      return;
    }
    await balloonProvider.setMin(newMin, guild.id);
    channel.send(
      baseEmbed().setDescription(
        `The minimum balloon volume has been set to **${newMin}**!
        This will apply on the next game.`,
      ),
    );
  },
});

export default balloonMin;
<file_sep>import { PrismaClient } from '@prisma/client';
import type { Redis } from 'ioredis';
import {
  DEFAULT_SHIRITORI_HAND,
  DEFAULT_SHIRITORI_MIN_LEN,
} from '../../games/shiritori/utils/defaults';
import { prisma as prismaClient } from '../prismaClient';
import {
  forShiritoriHand,
  forShiritoriMinLen,
  redis,
  redis as redisClient,
} from '../redisClient';

export class ShiritoriProvider {
  constructor(
    private readonly prisma: PrismaClient,
    private readonly redis: Redis,
  ) {}

  async setMinLen(guildId: string, minLen: number) {
    if (minLen < 0) throw new Error('Min length cannot be negative.');
    await this.prisma.guild
      .upsert({
        where: { guildId },
        update: {
          shiritori: {
            upsert: { update: { minLen }, create: { minLen } },
          },
        },
        create: {
          guildId,
          shiritori: { create: { minLen } },
        },
      })
      .then(() =>
        this.redis.set(forShiritoriMinLen(guildId), minLen, 'ex', 60),
      );
  }

  async getMinLen(guildId?: string) {
    if (!guildId) return DEFAULT_SHIRITORI_MIN_LEN;

    const ping = await redis.get(forShiritoriMinLen(guildId));
    if (ping) {
      this.redis.expire(forShiritoriMinLen(guildId), 60);
      return parseInt(ping, 10);
    }
    return this.prisma.shiritori
      .findUnique({
        where: { guildId },
        select: { minLen: true },
      })
      .then((res) => {
        const minLen = res?.minLen || DEFAULT_SHIRITORI_MIN_LEN;
        this.redis.set(forShiritoriMinLen(guildId), minLen, 'ex', 60);
        return minLen;
      });
  }

  async setHandSize(guildId: string, handSize: number) {
    if (handSize <= 0) throw new Error('Hand size must be at least one.');
    await this.prisma.guild
      .upsert({
        where: { guildId },
        update: {
          shiritori: {
            upsert: {
              update: { handSize },
              create: { handSize },
            },
          },
        },
        create: { guildId, shiritori: { create: { handSize } } },
      })
      .then(() => redis.set(forShiritoriHand(guildId), handSize, 'ex', 60));
  }

  async getHandSize(guildId: string) {
    const ping = await redis.get(forShiritoriHand(guildId));
    if (ping) {
      redis.expire(forShiritoriHand(guildId), 60);
      return parseInt(ping, 10);
    }
    return this.prisma.shiritori
      .findUnique({
        where: { guildId },
        select: { handSize: true },
      })
      .then((res) => {
        const handSize = res?.handSize || DEFAULT_SHIRITORI_HAND;
        redis.set(forShiritoriHand(guildId), handSize, 'ex', 60);
        return handSize;
      });
  }
}

export const shiritoriProvider = new ShiritoriProvider(
  prismaClient,
  redisClient,
);
<file_sep>import { CmdCategory } from '.prisma/client';
import { stripIndents } from 'common-tags';
import _ from 'lodash';
import { blogKey, redis } from '../../data/redisClient';
import { setCooldown } from '../../lib/cooldownManager';
import { sendPaginated } from '../../lib/sendPaginated';
import { baseEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';
import { blogPage } from './utils/embeds';
import { IBlogPost } from './utils/types';

const blog = new Command({
  name: 'blog',
  args: [],
  description: 'Read the Chika blog.',
  category: CmdCategory.FUN,
  channelCooldown: 10000,

  async execute(context) {
    const { channel } = context;

    // retrieve blog posts
    const posts: IBlogPost[] = await redis
      .lrange(blogKey, 0, -1)
      .then((posts) => posts.map((post) => JSON.parse(post)));

    if (posts.length < 1) {
      sendPaginated(channel, [
        baseEmbed().setTitle('ChikaSpot').setDescription(stripIndents`
        ...no posts here...ᕙ(⇀‸↼‶)ᕗ
        post something on the blog with \`post\`!`),
      ]);
    }

    // split into individual embeds
    const pages = _.chunk(posts, 5).map((_posts) => blogPage(_posts));

    // send as paginated
    sendPaginated(channel, pages, { timeout: 60000 });
    setCooldown(channel.id, this.name, this.channelCooldown!);
  },
});

export default blog;
<file_sep>-- DropForeignKey
ALTER TABLE "Arg" DROP CONSTRAINT "Arg_commandId_fkey";

-- AddForeignKey
ALTER TABLE "Arg" ADD FOREIGN KEY ("commandId") REFERENCES "Command"("id") ON DELETE CASCADE ON UPDATE CASCADE;
<file_sep>import type { StreamDispatcher, VoiceConnection } from 'discord.js';

interface QueueItem {
  id: string;
  url: string;
  title: string;
  thumbnailURL: string;
  duration: string;
}

interface AudioUtils {
  connection: VoiceConnection;
  dispatcher: StreamDispatcher;
  nowPlaying: QueueItem;
}

export type { QueueItem, AudioUtils };
<file_sep>import { MessageEmbed, User } from 'discord.js';
import { chika_pink, kaguya_red } from '../../../shared/assets';
import {
  baseEmbed,
  detectiveEmbed,
  lightErrorEmbed,
} from '../../../shared/embeds';
import type { ChatbotChar, IBlogPost } from './types';
import relativeTime from 'dayjs/plugin/relativeTime';
import dayjs from 'dayjs';
dayjs.extend(relativeTime);

export const avatarEmbed = (user: User): MessageEmbed =>
  baseEmbed()
    .setImage(user.displayAvatarURL({ dynamic: true }))
    .setFooter(`${user.tag}'s avatar`);

const applyColor = (embed: MessageEmbed, char: ChatbotChar) => {
  switch (char) {
    case 'ck':
      embed.setColor(chika_pink);
      break;
    case 'ka':
      embed.setColor(kaguya_red);
      break;
    default:
      embed.setColor(chika_pink);
      break;
  }
  return embed;
};

export const chatbotLoadingError = (char: ChatbotChar) => {
  const embed = new MessageEmbed()
    .setDescription(
      `Thanks for chatting with me! Please give me a minute to get ready.`,
    )
    .setFooter('(The API takes a moment to load sometimes lol)');
  return applyColor(embed, char);
};

export const chatbotTimeoutError = (char: ChatbotChar) => {
  const embed = lightErrorEmbed(
    'I ran into an error while thinking of a reply.',
  );
  return applyColor(embed, char);
};

export const chatbotLimitError = (char: ChatbotChar) => {
  const embed = lightErrorEmbed(
    "Sorry! I'll be hibernating for the rest of the month. (ღ˘⌣˘ღ)",
  ).setFooter(
    "(The free API I'm using has a 30k character limit per month lol)",
  );
  return applyColor(embed, char);
};

export const blogPage = (posts: IBlogPost[]): MessageEmbed => {
  return detectiveEmbed()
    .setTitle('ChikaSpot')
    .addFields(
      posts.map((post) => ({
        name: post.message,
        value: `${post.author} • ${dayjs(post.time).fromNow()}`,
      })),
    );
};
<file_sep>import { redis } from '../data/redisClient';
import type { Event } from '../types/event';
import { pubGuildCount, updateGuildList } from './lib/guild';

const guildCreate: Event = {
  name: 'guildDelete',
  once: false,
  listener: async (client) => {
    const { cache: guilds } = client.guilds;
    await pubGuildCount(redis, guilds.size);
    // update the list in redis
    const ids = guilds.map((guild) => guild.id);
    await updateGuildList(redis, ids);
  },
};

export default guildCreate;
<file_sep>import { Collection, Message, User } from 'discord.js';
import _ from 'lodash';
import { userProvider } from '../../../data/providers/userProvider';
import { prisma } from '../../../data/prismaClient';
import { forRibbons, redis } from '../../../data/redisClient';
import { sendPopped } from './embeds';

export const postGameBalloon = async (
  message: Message,
  players: Collection<string, User>,
) => {
  const { channel, author: popper } = message;

  const winners = players.filter((user) => user.id !== popper.id);
  const popperStock = await userProvider.getRibbons(popper);
  const winAmt = Math.floor(Math.random() * 20 + 20);
  const isBankrupt = winAmt * winners.size > popperStock;

  sendPopped(channel, { popper, isBankrupt, winAmt });

  await prisma
    .$transaction(
      winners.map((winner) =>
        prisma.user.upsert({
          where: { userId: winner.id },
          update: { ribbons: { increment: winAmt } },
          create: { userId: winner.id, tag: winner.tag, ribbons: winAmt },
          select: { userId: true, ribbons: true },
        }),
      ),
    )
    .then((res) =>
      redis.mset(
        ..._.flattenDeep(
          res.map((_res) => [forRibbons(_res.userId), _res.ribbons]),
        ),
      ),
    );

  userProvider.decrRibbons(popper, winAmt * winners.size);
};
<file_sep>import _ from 'lodash';
import {
  FuzzyDate,
  Maybe,
  MediaSource,
  MediaStatus,
} from '../../../generated/anilist';
import { unknown_png } from '../../../shared/assets';
import { baseEmbed } from '../../../shared/embeds';
import { parseHtml } from '../../../lib/typography';

interface mangaEmbedParams {
  title: string | null | undefined;
  description: string | null | undefined;
  status: MediaStatus | null | undefined;
  averageScore: number | null | undefined;
  coverImage: string | null | undefined;
  genres: Maybe<string>[] | null | undefined;
  source: MediaSource | null | undefined;
  startDate: FuzzyDate | null | undefined;
  endDate: FuzzyDate | null | undefined;
  volumes: number | null | undefined;
  chapters: number | null | undefined;
}

export function parseFuzzyDate(date?: FuzzyDate | null): string {
  if (!date) {
    return '?';
  }
  const { year, month, day } = date;
  if (!year && !month && !day) {
    return '?';
  }
  return `${day || '?'}-${month || '?'}-${year || '?'}`;
}

export const mangaInfoEmbed = (info: mangaEmbedParams) => {
  const {
    coverImage,
    title,
    description,
    status,
    genres,
    source,
    averageScore,
    startDate,
    endDate,
    chapters,
    volumes,
  } = info;
  return baseEmbed()
    .setThumbnail(coverImage || unknown_png)
    .setTitle(title)
    .setDescription(
      description ? parseHtml(description) : `*No description for this manga.*`,
    )
    .addFields([
      {
        name: ':pencil: Status',
        value: status
          ? _.startCase(status.replace(/_/g, ' ').toLowerCase())
          : '?',
        inline: true,
      },
      {
        name: ':calendar: Published',
        value: `From **${parseFuzzyDate(startDate)}** to **${parseFuzzyDate(
          endDate,
        )}**`,
        inline: true,
      },
      {
        name: ':ramen: Sauce',
        value: source
          ? _.startCase(source.replace(/_/g, ' ').toLowerCase())
          : '?',
        inline: true,
      },
    ])
    .addField(':shinto_shrine: Genres', genres?.join(', ') || ':question:')
    .addFields([
      { name: ':books: Volumes', value: volumes || '?', inline: true },
      {
        name: ':newspaper2: Chapters',
        value: chapters || '?',
        inline: true,
      },
      {
        name: ':star: Rating',
        value: averageScore ? `${averageScore}/100` : '?',
        inline: true,
      },
    ]);
};
<file_sep>import type { Message, User } from 'discord.js';
import { Guild } from 'discord.js';
import { sendNotAdmin, sendNotInGuild } from '../shared/embeds';
import type { GenericChannel } from '../types/command';

interface validateMessageOptions {
  channelId?: string;
  channel?: GenericChannel;
  channels?: GenericChannel[];
  guild?: Guild;
  guildId?: string;
  author?: User;
  authors?: User[];
  content?: RegExp;
  minLen?: number;
  maxLen?: number;
}

export const filterMessage = (
  message: Message,
  options: validateMessageOptions,
): Message | null => {
  const {
    channel,
    channels,
    channelId,
    guild,
    guildId,
    author,
    authors,
    content,
    minLen,
    maxLen,
  } = options;

  if (author && author.id !== message.author.id) return null;
  if (
    authors &&
    !authors.map((_author) => _author.id).includes(message.author.id)
  )
    return null;

  if (content && !content.test(message.content)) return null;
  if (minLen && message.content.length < minLen) return null;
  if (maxLen && message.content.length > maxLen) return null;

  if (channel && channel.id !== message.channel.id) return null;
  if (
    channels &&
    !channels.map((_channel) => _channel.id).includes(message.channel.id)
  )
    return null;
  if (channelId && message.channel.id !== channelId) return null;

  if (guild && guild.id !== message.guild?.id) return null;
  if (guildId && message.guild?.id !== guildId) return null;

  return message;
};

export const isAdmin = (message: Message) => {
  const { member, channel } = message;
  if (!member) {
    sendNotInGuild(channel);
    return false;
  }
  if (!member.hasPermission('ADMINISTRATOR')) {
    sendNotAdmin(channel);
    return false;
  }
  return true;
};
<file_sep>import { CmdCategory } from '@prisma/client';
import axios from 'axios';
import { userProvider } from '../../data/providers/userProvider';
import { forChikaInput, forChikaResponse } from '../../data/redisClient';
import { sendInsufficientRibbons } from '../../shared/embeds';
import { Command } from '../../types/command';
import {
  cacheInput,
  cacheResponse,
  genChatData,
  handleHuggingFaceError,
} from './utils/chatbot';

const chika = new Command({
  name: 'chika',
  aliases: ['ck'],
  args: [{ name: 'your_message', multi: true }],
  category: CmdCategory.FUN,
  description:
    'Chat with Chika. Be careful though, her IQ drops below 3 at times. This costs ribbons.',

  async execute(message, args) {
    const { channel, author } = message;

    const text = args.join(' ');

    const ribbonCost = text.length;
    const ribbonStock = await userProvider.getRibbons(author);
    if (ribbonCost > ribbonStock) {
      sendInsufficientRibbons(channel, ribbonCost, ribbonStock);
      return;
    }

    const data = await genChatData(channel.id, {
      input: forChikaInput,
      response: forChikaResponse,
      text,
    });

    axios
      .post(process.env.HUGGING_FACE_CHIKA, data, {
        headers: {
          Authorization: `Bearer ${process.env.HUGGING_FACE_CHIKA_KEY}`,
        },
      })
      .then((res) => {
        const reply = res.data.generated_text;
        channel.send(`> ${text}\n${reply}`);

        cacheInput(channel.id, text, forChikaInput);
        cacheResponse(channel.id, reply, forChikaResponse);

        userProvider.decrRibbons(author, ribbonCost);
      })
      .catch((err) => {
        handleHuggingFaceError(channel, err, 'ck', text);
      });
  },
});

export default chika;
<file_sep>import { CmdCategory } from '@prisma/client';
import type { DMChannel, Message, NewsChannel, TextChannel } from 'discord.js';

type GenericChannel = TextChannel | DMChannel | NewsChannel;

interface CommandArg {
  name: string;
  optional?: boolean;
  multi?: boolean;
}

interface ICommand {
  name: string;
  description: string;
  category: CmdCategory;
  args: CommandArg[];
  execute(message: Message, args: string[]): Promise<void>;

  aliases?: string[];
  channelCooldown?: number;
  userCooldown?: number;
}

export class Command implements ICommand {
  name: string;

  description: string;

  category: CmdCategory;

  usage: string;

  args: CommandArg[];

  execute: (message: Message, args: string[]) => Promise<void>;

  aliases?: string[];

  channelCooldown?: number;

  userCooldown?: number;

  constructor({
    name,
    description,
    category,
    args,
    aliases,
    channelCooldown,
    userCooldown,
    execute,
  }: ICommand) {
    this.name = name;
    this.description = description;
    this.category = category;
    this.args = args;
    this.aliases = aliases;
    this.channelCooldown = channelCooldown;
    this.userCooldown = userCooldown;
    this.execute = execute;

    this.usage = this.genUsage();
  }

  genUsage(): string {
    return `${this.name} ${this.args
      .map((arg) => {
        if (!arg.optional) return `<${arg.name}>`;
        if (arg.optional && !arg.multi) return `[${arg.name}]`;
        return `[${arg.name} ...]`;
      })
      .join(' ')}`;
  }
}

export type { GenericChannel };
<file_sep>import Discord from 'discord.js';
import { Command } from './command';
import { Game } from './game';
import { AudioUtils } from './queue';

interface DiscordClientCache {
  audioUtils: Discord.Collection<string, AudioUtils>;
  inGameStates: Discord.Collection<string, string>; // map ID to game title
}

declare module 'discord.js' {
  export interface Client {
    commands: Discord.Collection<string, Command>;
    commandsHelp: Discord.MessageEmbed;

    games: Discord.Collection<string, Game>;
    gamesList: string[];

    cache: DiscordClientCache;
  }
}
<file_sep>import { CmdCategory } from '@prisma/client';
import { lightErrorEmbed, sendNotInGuild } from '../../shared/embeds';
import { Command } from '../../types/command';

const boot = new Command({
  name: 'boot',
  description: 'Boot Chika from the voice channel. Queue is not cleared.',
  category: CmdCategory.MUSIC,
  args: [],

  async execute(message) {
    const { guild, client, channel } = message;
    if (!guild) {
      sendNotInGuild(channel);
      return;
    }
    const audioUtils = client.cache.audioUtils.get(guild.id);
    if (!audioUtils?.connection) {
      channel.send(lightErrorEmbed(`Bruv I'm not even in a voice channel.`));
      return;
    }
    audioUtils.connection.disconnect();
    channel.send(lightErrorEmbed(`I've left the voice channel.`));
    client.cache.audioUtils.delete(guild.id);
  },
});

export default boot;
<file_sep>import { CmdCategory } from '@prisma/client';
import type { Collection, EmbedFieldData, MessageEmbed } from 'discord.js';
import { baseEmbed, detectiveEmbed } from '../shared/embeds';
import type { Command } from '../types/command';
import { stripIndents } from 'common-tags';

export const genFullHelpEmbed = (
  commands: Collection<string, Command>,
): MessageEmbed => {
  const categoryMap: Record<CmdCategory, Command[]> = {} as any;
  commands.forEach((command) => {
    if (categoryMap[command.category]) {
      categoryMap[command.category].push(command);
    } else {
      categoryMap[command.category] = [command];
    }
  });
  const fields: EmbedFieldData[] = [];
  Object.keys(categoryMap).forEach((category) => {
    const cmds = categoryMap[category as CmdCategory].map(
      (command) => `\`${command.name}\``,
    );
    fields.push({
      name: tagEmoji(category as CmdCategory),
      value: cmds.join(', '),
    });
  });
  return detectiveEmbed().setTitle('Chika Commands').addFields(fields);
};

export const helpExtraInfo = baseEmbed().addField(
  'Some notes',
  stripIndents`
  • Chika's default prefix is **ck;**
  • For more info about a specific command, use \`help <command>\`
  • Chika's [dashboard](https://dashboard.chikawara.xyz) is ready! Currently open to admins only.`,
);

function tagEmoji(category: CmdCategory) {
  switch (category) {
    case 'CURRENCY':
      return ':moneybag: Currency';
    case 'FUN':
      return ':coffee: Fun';
    case 'GAMES':
      return ':video_game: Game';
    case 'MUSIC':
      return ':headphones: Music';
    case 'UTILITY':
      return ':satellite: Utility';
    default:
      return ':coffee: Fun';
  }
}
<file_sep>-- DropForeignKey
ALTER TABLE "Balloon" DROP CONSTRAINT "Balloon_guildId_fkey";

-- DropForeignKey
ALTER TABLE "Shiritori" DROP CONSTRAINT "Shiritori_guildId_fkey";

-- AlterTable
ALTER TABLE "Balloon" ALTER COLUMN "guildId" SET DATA TYPE TEXT,
ALTER COLUMN "minVol" DROP NOT NULL,
ALTER COLUMN "maxVol" DROP NOT NULL;

-- AlterTable
ALTER TABLE "Shiritori" ALTER COLUMN "guildId" SET DATA TYPE TEXT,
ALTER COLUMN "handSize" DROP NOT NULL;

-- AddForeignKey
ALTER TABLE "Shiritori" ADD FOREIGN KEY ("guildId") REFERENCES "Guild"("guildId") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Balloon" ADD FOREIGN KEY ("guildId") REFERENCES "Guild"("guildId") ON DELETE CASCADE ON UPDATE CASCADE;
<file_sep>import { commaListsAnd, stripIndents } from 'common-tags';
import type { User } from 'discord.js';
import { MessageEmbed } from 'discord.js';
import { GenericChannel } from '../types/command';
import {
  chika_crying_png,
  chika_detective_png,
  chika_peeking_png,
  chika_pink,
  chika_pointing_png,
  chika_rap_png,
  ribbon_emoji,
} from './assets';

export const baseEmbed = () => new MessageEmbed().setColor(chika_pink);

export const cryingEmbed = () => baseEmbed().setThumbnail(chika_crying_png);
export const detectiveEmbed = () =>
  baseEmbed().setThumbnail(chika_detective_png);
export const peekingEmbed = () => baseEmbed().setThumbnail(chika_peeking_png);
export const pointingEmbed = () => baseEmbed().setThumbnail(chika_pointing_png);
export const rappingEmbed = () => baseEmbed().setThumbnail(chika_rap_png);

export const lightErrorEmbed = (msg: string) =>
  baseEmbed().setDescription(`:broken_heart: ${msg}`);
export const lightOkEmbed = (msg: string) =>
  baseEmbed().setDescription(`:magic_wand: ${msg}`);

export const badCommandsEmbed = (...cmds: string[]) =>
  lightErrorEmbed(
    stripIndents(commaListsAnd`
  I couldn't understand these commands: ${cmds.map((cmd) => `**${cmd}**`)}
  To get a list of all the commands I know, use \`help\``),
  );

export const genericErrorEmbed = () =>
  cryingEmbed()
    .setTitle('Oh no!')
    .setDescription('I ran into an unknown error while running your request.');

export const withAuthorEmbed = (author: User) =>
  baseEmbed().setFooter(
    `Requested by ${author.tag}`,
    author.displayAvatarURL({ size: 32, dynamic: false }),
  );

export const sendNotInGuild = async (channel: GenericChannel) =>
  channel.send(lightErrorEmbed('This command can only be used in a server!'));

export const sendInsufficientRibbons = (
  channel: GenericChannel,
  cost: number,
  stock: number,
) =>
  channel.send(
    lightErrorEmbed(
      stripIndents`
      You don't have enough ribbons!
      You need **${cost}** ${ribbon_emoji}, but only have **${stock}** ${ribbon_emoji}.`,
    ),
  );

export const sendNotAdmin = (channel: GenericChannel) =>
  channel.send(lightErrorEmbed(`Only admins can  use that command!`));

export const sendBlockedCommand = (channel: GenericChannel, command: string) =>
  channel.send(
    lightErrorEmbed(`Command **${command}** has been disabled in this server.`),
  );
<file_sep>import type { MessageEmbed, MessageReaction, User } from 'discord.js';
import { GenericChannel } from '../types/command';
import { left_arrow, right_arrow } from '../shared/assets';
import dayjs from 'dayjs';

interface sendPaginatedOptions {
  leftEmoji?: string;
  rightEmoji?: string;
  timeout?: number;
}

// NOTE: need permissions to delete the reactions afterwards

export const sendPaginated = (
  channel: GenericChannel,
  pages: MessageEmbed[],
  options?: sendPaginatedOptions,
) => {
  const leftEmoji = options?.leftEmoji || left_arrow;
  const rightEmoji = options?.rightEmoji || right_arrow;
  const timeout = options?.timeout || 30000; // half a minute by default

  let on = 0; // page number
  const maxOn = pages.length - 1;
  channel
    .send(pages[on].setFooter(footer(1, maxOn + 1)))
    .then(async (message) => {
      await message.react(left_arrow).then(() => message.react(right_arrow));

      const filter = (reaction: MessageReaction, user: User) =>
        (reaction.emoji.name === leftEmoji ||
          reaction.emoji.name === rightEmoji) &&
        !user.bot;
      const collector = message.createReactionCollector(filter, {
        time: timeout,
        dispose: true,
      });

      const reactHandler = (reaction: MessageReaction) => {
        switch (reaction.emoji.name) {
          case left_arrow:
            on = on === 0 ? maxOn : on - 1;
            break;
          case right_arrow:
            on = on === maxOn ? 0 : on + 1;
            break;
          default:
            break;
        }
        message.edit(pages[on].setFooter(footer(on + 1, maxOn + 1)));
      };

      collector.on('remove', reactHandler);
      collector.on('collect', reactHandler);

      collector.on('end', () => {
        if (!message.deleted) {
          message.reactions.removeAll().catch((err) => console.error(err));
        }
      });
    });
};

function footer(curr: number, total: number) {
  return `${curr}/${total}  •  ${dayjs().format(
    'ddd h:ma',
  )}, Chika Standard Time`;
}
<file_sep>import { CmdCategory } from '@prisma/client';
import { userProvider } from '../../data/providers/userProvider';
import { groupNum } from '../../lib/typography';
import { ribbon_emoji } from '../../shared/assets';
import { baseEmbed, lightErrorEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';

const give = new Command({
  name: 'give',
  args: [{ name: 'user' }, { name: 'amount' }],
  description: `Be charitable and give another user some ribbons.`,
  category: CmdCategory.CURRENCY,
  aliases: ['donate'],

  async execute(message, args) {
    const { author, channel, mentions } = message;
    const beneficiary = mentions.users.first();
    if (!beneficiary) {
      channel.send(lightErrorEmbed(`Tag someone to give ${ribbon_emoji} to!`));
      return;
    }
    if (beneficiary.bot) {
      channel.send(
        lightErrorEmbed(
          `**${beneficiary.tag}** is a bot and does not care for ${ribbon_emoji}...`,
        ),
      );
      return;
    }

    const donation = parseInt(args[args.length - 1], 10);
    if (Number.isNaN(donation)) {
      channel.send(lightErrorEmbed(`Please use a valid number!`));
      return;
    }
    const benefactorStock = await userProvider.getRibbons(author);
    if (donation > benefactorStock) {
      channel.send(
        lightErrorEmbed(
          `LOL you're too poor to do that! You only have ${benefactorStock} ${ribbon_emoji}.`,
        ),
      );
      return;
    }

    userProvider.incrRibbons(beneficiary, donation);
    userProvider.decrRibbons(author, donation);

    channel.send(
      baseEmbed().setDescription(
        `**${beneficiary.username}** has received **${groupNum.format(
          donation,
        )}** ${ribbon_emoji} from ${author.username}!`,
      ),
    );
  },
});

export default give;
<file_sep>import { CmdCategory } from '.prisma/client';
import { ishigami_cake_png } from '../../shared/assets';
import { baseEmbed, lightErrorEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';
import { withAnd } from '../../lib/typography';

const birthday = new Command({
  name: 'birthday',
  aliases: ['bd'],
  args: [{ name: 'user' }, { name: 'more_users', optional: true, multi: true }],
  category: CmdCategory.FUN,
  description: 'Wish your m8s a happy birthday.',
  execute: async (message) => {
    const { author, mentions, channel } = message;
    if (!mentions.users.size) {
      channel.send(lightErrorEmbed('Tag someone in your birthday wish!'));
      return;
    }
    const births = mentions.users.map((user) => user.toString());
    channel.send(
      baseEmbed()
        .setDescription(
          `${author.toString()} realizes that ${withAnd(births)} ${
            births.length === 1 ? 'has' : 'have'
          } aged one year since 365 days ago.`,
        )
        .setImage(ishigami_cake_png)
        .setFooter('Happy Birthday!!'),
    );
  },
});

export default birthday;
<file_sep>import Redis, { KeyType } from 'ioredis';

const withPrefix = (keyPrefix: string) =>
  new Redis(process.env.REDISCLOUD_URL, {
    keyPrefix: `${keyPrefix}:`,
  });

export const redis = new Redis(process.env.REDISCLOUD_URL);
export const redisQueue = withPrefix('queue');

export type redisPrefixer = (key: KeyType) => string;

export const forNowPlaying = (guildId: KeyType) => `nowplaying:${guildId}`;
export const forPrefix = (key: KeyType) => `prefix:${key}`;
export const forBlockedCommands = (guildId: string) => `blocked:${guildId}`;
export const forRibbons = (key: KeyType) => `ribbons:${key}`;
export const forBalloonMin = (key: KeyType) => `balloon:min:${key}`;
export const forBalloonMax = (key: KeyType) => `balloon:max:${key}`;
export const forShiritoriMinLen = (key: KeyType) => `shiritori:minlen:${key}`;
export const forShiritoriHand = (key: KeyType) => `shiritori:hand:${key}`;
export const forChikaInput = (key: KeyType) => `chika:input:${key}`;
export const forChikaResponse = (key: KeyType) => `chika:response:${key}`;
export const forKaguyaInput = (key: KeyType) => `kaguya:input:${key}`;
export const forKaguyaResponse = (key: KeyType) => `kaguya:response:${key}`;
export const forCooldown = (key: KeyType) => `cooldown:${key}`;
export const blogKey = 'blog';
<file_sep>import { CmdCategory } from '@prisma/client';
import { lightErrorEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';
import { sendMusicOnlyInGuild, sendNowPlaying } from './utils/embeds';

const nowPlaying = new Command({
  name: 'now-playing',
  aliases: ['np'],
  args: [],
  description: 'Show the currently playing track.',
  category: CmdCategory.MUSIC,

  async execute(message) {
    const { guild, client, channel } = message;
    if (!guild) {
      sendMusicOnlyInGuild(channel);
      return;
    }

    const audioUtils = client.cache.audioUtils.get(guild.id);
    if (!audioUtils?.nowPlaying) {
      channel.send(lightErrorEmbed('The sound of silence.'));
      return;
    }

    sendNowPlaying(channel, audioUtils.nowPlaying, {
      streamTime: audioUtils.dispatcher.streamTime,
    });
  },
});

export default nowPlaying;
<file_sep>-- CreateTable
CREATE TABLE "User" (
    "id" SERIAL NOT NULL,
    "userId" TEXT NOT NULL,
    "username" TEXT NOT NULL,
    "ribbons" INTEGER NOT NULL,

    PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "Guild" (
    "id" SERIAL NOT NULL,
    "guildId" TEXT NOT NULL,
    "prefix" TEXT,

    PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "Shiritori" (
    "id" SERIAL NOT NULL,
    "guildId" INTEGER NOT NULL,
    "handSize" INTEGER NOT NULL,

    PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "Balloon" (
    "id" SERIAL NOT NULL,
    "guildId" INTEGER NOT NULL,
    "minVol" INTEGER NOT NULL,
    "maxVol" INTEGER NOT NULL,

    PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "User.userId_unique" ON "User"("userId");

-- CreateIndex
CREATE UNIQUE INDEX "Guild.guildId_unique" ON "Guild"("guildId");

-- CreateIndex
CREATE UNIQUE INDEX "Shiritori.guildId_unique" ON "Shiritori"("guildId");

-- CreateIndex
CREATE UNIQUE INDEX "Balloon.guildId_unique" ON "Balloon"("guildId");

-- AddForeignKey
ALTER TABLE "Shiritori" ADD FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Balloon" ADD FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
<file_sep>import type { Collection, User } from 'discord.js';
import { ribbon_emoji } from '../../../shared/assets';
import { baseEmbed, lightErrorEmbed } from '../../../shared/embeds';
import { GenericChannel } from '../../../types/command';
import { groupNum } from '../../../lib/typography';
import { toListString } from '../../music/utils/embeds';

export const sendRibbonStock = (
  channel: GenericChannel,
  stock: Collection<User, string | null>,
) => {
  const lines = stock.map(
    (count, user) => `${user.toString()}: ${count || 0} ${ribbon_emoji}`,
  );
  channel.send(
    baseEmbed().setDescription(toListString(lines, { noNum: true })),
  );
};

export const sendTop = (
  channel: GenericChannel,
  top: {
    tag: string;
    ribbons: number;
  }[],
  options?: { locale?: string; thumbnail?: string | null },
) => {
  const lines = top.map(
    ({ tag, ribbons }) =>
      `**${tag}**: ${groupNum.format(ribbons)} ${ribbon_emoji}`,
  );
  const partialEmbed = baseEmbed()
    .setTitle(`Wealth Gap in ${options?.locale || 'za Warudo'} :yen:`)
    .setDescription(toListString(lines))
    .setFooter(`Showing top ${lines.length}`);

  channel.send(
    options?.thumbnail
      ? partialEmbed.setThumbnail(options.thumbnail)
      : partialEmbed,
  );
};

export const sendExceededMaxTake = (channel: GenericChannel) =>
  channel.send(lightErrorEmbed('I can only fetch a maximum of 20 results.'));
<file_sep>import { mocked } from 'ts-jest/utils';
import {
  DEFAULT_MAX_BALLOON,
  DEFAULT_MIN_BALLOON,
} from '../../games/balloon/utils/defaults';
import { prisma } from '../prismaClient';
import { forBalloonMax, forBalloonMin, redis } from '../redisClient';
import { BalloonProvider } from './balloonProvider';
jest.mock('../redisClient');

const mockRedis = mocked(redis, true);

const balloonProvider = new BalloonProvider(prisma, mockRedis);

afterEach(async () => {
  await prisma.guild.deleteMany();
  mockRedis.set.mockReset();
});

afterAll(async () => {
  await prisma.$disconnect();
});

describe('#setMin', () => {
  test('uses upsert', async () => {
    const upsert = jest.spyOn(prisma.guild, 'upsert');
    await balloonProvider.setMin(10, '1');
    expect(upsert).toBeCalledTimes(1);
    upsert.mockRestore();
  });

  test('caches minVol to redis with 60 seconds ttl', async () => {
    const upsert = jest.spyOn(prisma.guild, 'upsert');
    await balloonProvider.setMin(10, '1');
    expect(mockRedis.set).toBeCalledWith(forBalloonMin('1'), 10, 'ex', 60);
    upsert.mockRestore();
  });

  test('throws an error if negative volume provided', () => {
    return expect(balloonProvider.setMin(-1, '1')).rejects.toThrow();
  });

  describe('guild does not exist in db', () => {
    test('creates the guild with balloon settings applied', async () => {
      const _guild = await prisma.guild.findUnique({ where: { guildId: '1' } });
      expect(_guild).toBeNull(); // check null

      await balloonProvider.setMin(10, '1');
      const res = await prisma.balloon.findUnique({
        where: { guildId: '1' },
        select: { minVol: true },
      });
      expect(res?.minVol).toBe(10);
    });
  });

  describe('guild exists in db', () => {
    describe('without minVol', () => {
      beforeEach(async () => {
        await prisma.guild.create({ data: { guildId: '1' } });
      });
      test('creates a minVol for the guild', async () => {
        await balloonProvider.setMin(10, '1');
        const res = await prisma.balloon.findUnique({
          where: { guildId: '1' },
          select: { minVol: true },
        });
        expect(res?.minVol).toBe(10);
      });
    });

    describe('with minVol', () => {
      beforeEach(async () => {
        await prisma.guild.create({
          data: { guildId: '1', balloon: { create: { minVol: 10 } } },
        });
      });
      test('updates minVol', async () => {
        await balloonProvider.setMin(20, '1');
        const res = await prisma.balloon.findUnique({
          where: { guildId: '1' },
          select: { minVol: true },
        });
        expect(res?.minVol).toBe(20);
      });
    });
  });
});

describe('#getMin', () => {
  describe('finds in redis', () => {
    beforeEach(() => {
      mockRedis.get.mockResolvedValueOnce('10');
    });

    test('should not hit db', async () => {
      const findUnique = jest.spyOn(prisma.balloon, 'findUnique');
      await balloonProvider.getMin('1');
      expect(findUnique).not.toBeCalled();
      findUnique.mockRestore();
    });

    test('return value from redis as int', async () => {
      const res = await balloonProvider.getMin('1');
      expect(res).toBe(10);
    });

    test('sets 60 seconds ttl', async () => {
      await balloonProvider.getMin('1');
      expect(mockRedis.expire).toBeCalledWith(forBalloonMin('1'), 60);
    });
  });

  describe('could not find in redis', () => {
    beforeEach(() => {
      mockRedis.get.mockResolvedValueOnce(null);
    });

    describe('exists in db', () => {
      beforeEach(async () => {
        await prisma.guild.create({
          data: { guildId: '1', balloon: { create: { minVol: 1 } } },
        });
      });

      test('returns the value from db', async () => {
        const res = await balloonProvider.getMin('1');
        expect(res).toBe(1);
      });

      test('caches the value in redis', async () => {
        await balloonProvider.getMin('1');
        expect(mockRedis.set).toBeCalledWith(forBalloonMin('1'), 1, 'ex', 60);
      });
    });

    describe('does not exist in db', () => {
      test('returns the default', async () => {
        const res = await balloonProvider.getMin('1');
        expect(res).toBe(DEFAULT_MIN_BALLOON);
      });

      test('caches the value in redis', async () => {
        await balloonProvider.getMin('1');
        expect(mockRedis.set).toBeCalledWith(
          forBalloonMin('1'),
          DEFAULT_MIN_BALLOON,
          'ex',
          60,
        );
      });
    });
  });
});

describe('#setMax', () => {
  test('uses upsert', async () => {
    const upsert = jest.spyOn(prisma.guild, 'upsert');
    await balloonProvider.setMax(10, '1');
    expect(upsert).toBeCalledTimes(1);
    upsert.mockRestore();
  });

  test('caches maxVol to redis with 60 seconds ttl', async () => {
    const upsert = jest.spyOn(prisma.guild, 'upsert');
    await balloonProvider.setMax(10, '1');
    expect(mockRedis.set).toBeCalledWith(forBalloonMax('1'), 10, 'ex', 60);
    upsert.mockRestore();
  });

  test('throws an error if negative volume provided', () => {
    return expect(balloonProvider.setMax(-1, '1')).rejects.toThrow();
  });

  describe('guild does not exist in db', () => {
    test('creates the guild with balloon settings applied', async () => {
      const _guild = await prisma.guild.findUnique({ where: { guildId: '1' } });
      expect(_guild).toBeNull(); // check null

      await balloonProvider.setMax(10, '1');
      const res = await prisma.balloon.findUnique({
        where: { guildId: '1' },
        select: { maxVol: true },
      });
      expect(res?.maxVol).toBe(10);
    });
  });

  describe('guild exists in db', () => {
    describe('without maxVol', () => {
      beforeEach(async () => {
        await prisma.guild.create({ data: { guildId: '1' } });
      });
      test('creates a maxVol for the guild', async () => {
        await balloonProvider.setMax(10, '1');
        const res = await prisma.balloon.findUnique({
          where: { guildId: '1' },
          select: { maxVol: true },
        });
        expect(res?.maxVol).toBe(10);
      });
    });

    describe('with maxVol', () => {
      beforeEach(async () => {
        await prisma.guild.create({
          data: { guildId: '1', balloon: { create: { maxVol: 10 } } },
        });
      });
      test('updates maxVol', async () => {
        await balloonProvider.setMax(20, '1');
        const res = await prisma.balloon.findUnique({
          where: { guildId: '1' },
          select: { maxVol: true },
        });
        expect(res?.maxVol).toBe(20);
      });
    });
  });
});

describe('#getMax', () => {
  describe('finds in redis', () => {
    beforeEach(() => {
      mockRedis.get.mockResolvedValueOnce('10');
    });

    test('should not hit db', async () => {
      const findUnique = jest.spyOn(prisma.balloon, 'findUnique');
      await balloonProvider.getMax('1');
      expect(findUnique).not.toBeCalled();
      findUnique.mockRestore();
    });

    test('return value from redis as int', async () => {
      const res = await balloonProvider.getMax('1');
      expect(res).toBe(10);
    });

    test('sets 60 seconds ttl', async () => {
      await balloonProvider.getMax('1');
      expect(mockRedis.expire).toBeCalledWith(forBalloonMax('1'), 60);
    });
  });

  describe('could not find in redis', () => {
    beforeEach(() => {
      mockRedis.get.mockResolvedValueOnce(null);
    });

    describe('exists in db', () => {
      beforeEach(async () => {
        await prisma.guild.create({
          data: { guildId: '1', balloon: { create: { maxVol: 1 } } },
        });
      });

      test('returns the value from db', async () => {
        const res = await balloonProvider.getMax('1');
        expect(res).toBe(1);
      });

      test('caches the value in redis', async () => {
        await balloonProvider.getMax('1');
        expect(mockRedis.set).toBeCalledWith(forBalloonMax('1'), 1, 'ex', 60);
      });
    });

    describe('does not exist in db', () => {
      test('returns the default', async () => {
        const res = await balloonProvider.getMax('1');
        expect(res).toBe(DEFAULT_MAX_BALLOON);
      });

      test('caches the value in redis', async () => {
        await balloonProvider.getMax('1');
        expect(mockRedis.set).toBeCalledWith(
          forBalloonMax('1'),
          DEFAULT_MAX_BALLOON,
          'ex',
          60,
        );
      });
    });
  });
});
<file_sep>import { CmdCategory } from '@prisma/client';
import { balloonGame } from '../../games/balloon';
import { Command } from '../../types/command';

const balloonRules = new Command({
  name: 'balloon-rules',
  aliases: ['bl-rules'],
  args: [],
  category: CmdCategory.GAMES,
  description: 'Check the rules for Balloon.',

  async execute(message) {
    const { channel } = message;
    channel.send(balloonGame.rules);
  },
});

export default balloonRules;
<file_sep>interface ChatbotInput {
  inputs: {
    past_user_inputs?: string[];
    generated_responses?: string[];
    text: string;
  };
}

type ChatbotChar = 'ck' | 'ka';

interface IBlogPost {
  author: string; // Discord tag
  time: number; // unix timestamp
  message: string;
}

export type { ChatbotInput, ChatbotChar, IBlogPost };
<file_sep>import { CmdCategory } from '@prisma/client';
import { redisQueue } from '../../data/redisClient';
import { Command } from '../../types/command';
import {
  sendAddedToQueue,
  sendMusicOnlyInGuild,
  sendNoVideo,
} from './utils/embeds';
import { validateArgs } from './utils/youtube';

const insert = new Command({
  name: 'insert',
  aliases: ['addd'],
  category: CmdCategory.MUSIC,
  description: 'Inserts a track to the front of the queue.',
  args: [{ name: 'url_or_title', multi: true }],

  async execute(message, args) {
    const { channel, guild, author } = message;
    if (!guild) {
      sendMusicOnlyInGuild(channel);
      return;
    }

    const videoData = await validateArgs(args);
    if (!videoData) {
      sendNoVideo(channel, args.join(' '));
      return;
    }

    redisQueue.lpush(guild.id, JSON.stringify(videoData));
    sendAddedToQueue(channel, { videoData, author });
  },
});

export default insert;
<file_sep>import { PrismaClient } from '.prisma/client';
import type { User } from 'discord.js';
import { Redis } from 'ioredis';
import { prisma as prismaClient } from '../prismaClient';
import { forRibbons, redis as redisClient } from '../redisClient';

export class UserProvider {
  constructor(
    private readonly prisma: PrismaClient,
    private readonly redis: Redis,
  ) {}

  async getRibbons(user: User) {
    const ping = await this.redis.get(forRibbons(user.id));
    if (ping) {
      this.redis.expire(forRibbons(user.id), 60);
      return parseInt(ping, 10);
    }
    return this.prisma.user
      .findUnique({
        where: { userId: user.id },
        select: { ribbons: true },
      })
      .then((res) => {
        const ribbons = res?.ribbons || 0;
        this.redis.set(forRibbons(user.id), ribbons, 'ex', 60);
        return ribbons;
      });
  }

  async incrRibbons(user: User, incrby: number) {
    if (incrby < 0) throw new Error('Got a negative value to increment.');
    return this.prisma.user
      .upsert({
        where: { userId: user.id },
        update: { ribbons: { increment: incrby } },
        create: { userId: user.id, tag: user.tag, ribbons: incrby },
      })
      .then((_user) =>
        this.redis.set(forRibbons(user.id), _user.ribbons, 'ex', 60),
      );
  }

  async decrRibbons(user: User, decrby: number) {
    if (decrby < 0) throw new Error('Received negative decrby value.');
    await this.prisma
      .$transaction([
        this.prisma.$executeRaw<number>`
        INSERT INTO "User" ("userId", tag, ribbons)
        VALUES (${user.id}, ${user.tag}, ${0})
        ON CONFLICT ("userId") DO UPDATE
        SET ribbons =
          CASE
            WHEN "User".ribbons < ${decrby} THEN ${0}
            ELSE "User".ribbons - ${decrby}
          END;`,
        this.prisma.user.findUnique({
          where: { userId: user.id },
          select: { ribbons: true },
        }),
      ])
      .then((_res) => {
        const [, res] = _res;
        const ribbons = res?.ribbons || 0;
        this.redis.set(forRibbons(user.id), ribbons, 'ex', 60);
      });
  }

  async getGlobalTopRibbons(take = 10) {
    return this.prisma.user.findMany({
      take,
      orderBy: { ribbons: 'desc' },
      select: { tag: true, ribbons: true },
    });
  }

  async getTopRibbonsForUsers(members: User[], take = 10) {
    const IDs = members.map((member) => member.id);
    return this.prisma.user.findMany({
      take,
      select: { ribbons: true, tag: true },
      where: { userId: { in: IDs } },
      orderBy: { ribbons: 'desc' },
    });
  }
}

export const userProvider = new UserProvider(prismaClient, redisClient);
<file_sep>import { CmdCategory } from '@prisma/client';
import { shiritoriProvider } from '../../data/providers/shiritoriProvider';
import {
  baseEmbed,
  lightErrorEmbed,
  sendNotInGuild,
} from '../../shared/embeds';
import { Command } from '../../types/command';

const shiritoriMinLen = new Command({
  name: 'shiritori-minlen',
  aliases: ['sh-min'],
  args: [{ name: 'new_min', optional: true }],
  category: CmdCategory.GAMES,
  description: 'Check or set the minimum word length in Shiritori.',

  async execute(message, args) {
    const { channel, guild } = message;
    if (!guild) {
      sendNotInGuild(channel);
      return;
    }

    const [_newMin] = args;
    if (!_newMin) {
      const currMin = await shiritoriProvider.getMinLen(guild.id);
      channel.send(
        baseEmbed().setDescription(`Current min word length: **${currMin}**`),
      );
      return;
    }

    const newMin = parseInt(_newMin, 10);
    if (Number.isNaN(newMin)) {
      channel.send(lightErrorEmbed(`Please give me a valid number!`));
      return;
    }
    if (newMin < 0) {
      channel.send(lightErrorEmbed(`Bruh.`));
      return;
    }
    await shiritoriProvider.setMinLen(guild.id, newMin);
    channel.send(
      baseEmbed().setDescription(
        `The minimum word length for Shiritori has been set to **${newMin}**!
			This will apply on the next game.`,
      ),
    );
  },
});

export default shiritoriMinLen;
<file_sep>import { Message } from 'discord.js';
import { guildProvider } from '../data/providers/guildProvider';
import { validateArgsCount } from '../lib/validateArgsCount';
import { isOnCooldown } from '../lib/validateCooldowns';
import { DEFAULT_PREFIX } from '../shared/constants';
import { badCommandsEmbed, sendBlockedCommand } from '../shared/embeds';
import { CriticalError } from '../shared/errors';
import { Event } from '../types/event';

// TODO: add better arg validation
// check arg types too

const message: Event = {
  name: 'message',
  once: false,
  async listener(client, message: Message) {
    const { guild, content, author, channel } = message;
    if (author.bot) return;

    // check prefix
    let prefix = DEFAULT_PREFIX;
    if (guild) {
      prefix = await guildProvider.getPrefix(guild.id);
    }
    const prefixRe = new RegExp(`^${prefix}`, 'i');
    if (!prefixRe.test(content)) return;

    // check if command exists
    const args = content.split(/ +/);
    const sentCommand = args.shift()?.toLowerCase().replace(prefix, '');
    if (!sentCommand) return;
    const command = client.commands.find(
      (_command) =>
        _command.name === sentCommand ||
        !!_command.aliases?.includes(sentCommand),
    );
    if (!command) {
      channel.send(badCommandsEmbed(sentCommand));
      return;
    }

    // check if command is blocked
    if (guild) {
      const isBlocked = await guildProvider.isBlocked(guild.id, command.name);
      if (isBlocked) {
        sendBlockedCommand(channel, command.name);
        return;
      }
    }

    // check argument count
    if (!validateArgsCount(command, args, channel)) return;

    if (await isOnCooldown(message, command)) return;

    try {
      command.execute(message, args);
    } catch (err) {
      console.error(err);
      if (err instanceof CriticalError) throw err;
    }
  },
};

export default message;
<file_sep>import { CmdCategory } from '@prisma/client';
import { redisQueue } from '../../data/redisClient';
import { lightErrorEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';
import { QueueItem } from '../../types/queue';
import { tryToConnect } from './utils/client';
import {
  sendAddedToQueue,
  sendMusicOnlyInGuild,
  sendNotInVoiceChannel,
  sendNoVideo,
  sendNoVoicePermissions,
} from './utils/embeds';
import { createFinishListener } from './utils/listener';
import { playThis, validateArgs } from './utils/youtube';

const play = new Command({
  name: 'play',
  aliases: ['tunes'],
  args: [{ name: 'url_or_title', multi: true, optional: true }],
  category: CmdCategory.MUSIC,
  description: 'Let Chika play some music from YouTube for you.',

  async execute(message, args) {
    const { channel, member, guild, client, author } = message;
    if (!guild) {
      sendMusicOnlyInGuild(channel);
      return;
    }
    if (!member?.voice.channel) {
      sendNotInVoiceChannel(channel);
      return;
    }

    const next = await redisQueue.lpop(guild.id);
    const audioUtils = client.cache.audioUtils.get(guild.id);

    // they called ck;play with no args
    if (args.length === 0) {
      if (!next) {
        channel.send(
          lightErrorEmbed('There are no songs in the queue for me to play.'),
        );
        return;
      }
      if (audioUtils) {
        channel.send(lightErrorEmbed("I'm already playing some music!"));
        return;
      }
      const connection = await tryToConnect(member.voice.channel);
      if (!connection) {
        sendNoVoicePermissions(channel);
        return;
      }
      connection.on('disconnect', () =>
        client.cache.audioUtils.delete(guild.id),
      );
      const nextData = JSON.parse(next) as QueueItem;
      playThis(connection, nextData, {
        channel,
        client,
        guildId: guild.id,
        onFinish: createFinishListener(guild, {
          client,
          channel,
        }),
      });
      return;
    }

    if (next) redisQueue.lpush(guild.id, next); // push next track back

    const videoData = await validateArgs(args);
    if (!videoData) {
      sendNoVideo(channel, args.join(' '));
      return;
    }

    // there's a song playing
    // push to the redis queue
    if (audioUtils) {
      redisQueue.rpush(guild.id, JSON.stringify(videoData));
      sendAddedToQueue(channel, { videoData, author });
      return;
    }

    // nothing is playing now
    // start playing
    const connection = await tryToConnect(member.voice.channel);
    if (!connection) {
      sendNoVoicePermissions(channel);
      return;
    }
    connection.on('disconnect', () => client.cache.audioUtils.delete(guild.id));
    playThis(connection, videoData, {
      channel,
      client,
      guildId: guild.id,
      onFinish: createFinishListener(guild, { channel, client }),
    });
  },
});

export default play;
<file_sep>import type { Message } from 'discord.js';
import ms from 'ms';
import { lightErrorEmbed } from '../shared/embeds';
import type { Command } from '../types/command';
import { getCooldown } from './cooldownManager';

export const isOnCooldown = async (
  message: Message,
  command: Command,
): Promise<boolean> => {
  const { channel, author } = message;
  if (command.channelCooldown) {
    const pttl = await getCooldown(channel.id, command.name);
    if (pttl) {
      channel.send(
        lightErrorEmbed(
          `Please wait ${ms(pttl)} before using **${
            command.name
          }** in this channel again.`,
        ),
      );
      return true;
    }
  }
  if (command.userCooldown) {
    const pttl = await getCooldown(author.id, command.name);
    if (pttl) {
      message.channel.send(
        lightErrorEmbed(
          `**${author.username}**, please wait ${ms(pttl)} before using **${
            command.name
          }** again.`,
        ),
      );
      return true;
    }
  }
  return false;
};
<file_sep>import { endOfToday, secToWordString } from './time';

describe('#secToWordString', () => {
  describe('given exact hour', () => {
    it('should return hour only', () => {
      expect(secToWordString(3600)).toEqual('1h');
    });
  });
  describe('given exact minute', () => {
    it('should return minute only', () => {
      expect(secToWordString(300)).toEqual('5min');
    });
  });
  describe('given exact second', () => {
    it('should return second only', () => {
      expect(secToWordString(45)).toEqual('45s');
    });
  });
  describe('given mix of hours, minutes, and seconds', () => {
    it('should convert correctly', () => {
      expect(secToWordString(19230)).toEqual('5h 20min 30s');
    });
  });
});

describe('#endOfToday', () => {
  const dateSpy = jest.spyOn(Date.prototype, 'setHours').mockReturnValue(420);

  beforeEach(() => {
    dateSpy.mockClear();
  });

  it('should call Date with 23, 59, 59, 999', () => {
    endOfToday();
    expect(dateSpy).toBeCalledTimes(1);
    expect(dateSpy).toBeCalledWith(23, 59, 59, 999);
  });
  it('should call valueOf and return a number', () => {
    expect(endOfToday()).toBe(420);
  });
});
<file_sep>import { CmdCategory } from '@prisma/client';
import { userProvider } from '../../data/providers/userProvider';
import { ribbon_emoji } from '../../shared/assets';
import { baseEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';

const ribbon = new Command({
  name: 'ribbon',
  description: 'Check how many ribbons you or another user has.',
  args: [{ name: 'user', optional: true }],
  category: CmdCategory.CURRENCY,
  aliases: ['r'],
  async execute(message) {
    const { mentions, author, channel } = message;
    const user = mentions.users.first() || author;

    const stock = await userProvider.getRibbons(user);
    channel.send(
      baseEmbed().setDescription(
        `**${user.tag}** has **${stock}** ${ribbon_emoji}`,
      ),
    );
  },
});

export default ribbon;
<file_sep>import { Client, Guild, Message } from 'discord.js';
import { forNowPlaying, redis, redisQueue } from '../../../data/redisClient';
import { GenericChannel } from '../../../types/command';
import { QueueItem } from '../../../types/queue';
import { sendAddedToQueue, sendFinishedAllTracks } from './embeds';
import { playThis } from './youtube';

interface createFinishListenerParams {
  channel: GenericChannel;
  client: Client;
}

export function createFinishListener(
  guild: Guild,
  { channel, client }: createFinishListenerParams,
) {
  const onFinish = async () => {
    const audioUtils = client.cache.audioUtils.get(guild.id)!;
    if (!audioUtils) return;
    redisQueue
      .lpop(guild.id)
      .then(async (res) => {
        if (!res) {
          sendFinishedAllTracks(channel);
          audioUtils.dispatcher.destroy();
          audioUtils.connection.disconnect();
          redis.del(forNowPlaying(guild.id));
          return;
        }
        const nextData = JSON.parse(res) as QueueItem;
        playThis(audioUtils.connection, nextData, {
          channel,
          client,
          guildId: guild.id,
          onFinish,
        });
      })
      .catch((err) => console.error(err));
  };
  return onFinish;
}

interface createResultSelectListenerParams {
  channelId: string;
  guildId: string;
}

export const createResultSelectListener = (
  results: QueueItem[],
  { channelId, guildId }: createResultSelectListenerParams,
) => {
  const resultSelectListener = async (message: Message) => {
    const { content, channel, author } = message;
    if (channelId !== channel.id) return;
    const index = parseInt(content, 10);
    if (Number.isNaN(index) || index > results.length) return;

    const selectedTrack = results[index - 1];
    redisQueue.rpush(guildId, JSON.stringify(selectedTrack));
    sendAddedToQueue(channel, { videoData: selectedTrack, author });
  };

  return resultSelectListener;
};
<file_sep>export const endOfToday = () => new Date().setHours(23, 59, 59, 999);

export const secToWordString = (sec: number): string => {
  const h = Math.floor(sec / 3600);
  const subHour = sec % 3600;
  const s = subHour % 60;
  const m = (subHour - s) / 60;
  return `${h ? `${h}h` : ''} ${m ? `${m}min` : ''} ${s ? `${s}s` : ''}`.trim();
};
<file_sep>import { lightErrorEmbed } from '../shared/embeds';
import { Command, GenericChannel } from '../types/command';

export function validateArgsCount(
  command: Command,
  args: string[],
  channel: GenericChannel,
): string[] | null {
  let softMax = 0;
  let hasNoMulti = true;
  command.args.forEach((arg) => {
    if (!arg.optional) softMax += 1;
    if (arg.optional && !arg.multi) softMax += 1;
    if (arg.multi) hasNoMulti = false;
  });

  const min = command.args.filter((arg) => !arg.optional).length;
  const hasNoOptional = min === command.args.length;

  const provided = args.length;

  // only non-optional args
  // but either failed to give exact or gave too little
  if (
    hasNoOptional &&
    ((hasNoMulti && provided !== min) || (!hasNoMulti && provided < min))
  ) {
    channel.send(
      lightErrorEmbed(
        `Command **${command.name}** expected ${min} ${
          min === 1 ? 'argument' : 'arguments'
        } but got ${provided}.
        Use \`help ${command.name}\` to see usage info.`,
      ),
    );
    return null;
  }

  // has optional but gave less than min
  if (!hasNoOptional && provided < min) {
    channel.send(
      lightErrorEmbed(
        `Command **${command.name}** expected at least ${min} ${
          min === 1 ? 'argument' : 'arguments'
        } but got ${provided}.
        Use \`help ${command.name}\` to see usage info.`,
      ),
    );
    return null;
  }

  // has optional and no multi and gave more than soft cap
  if (!hasNoOptional && hasNoMulti && provided > softMax) {
    channel.send(
      lightErrorEmbed(
        `Command **${command.name}** expected at most ${softMax} ${
          softMax === 1 ? 'argument' : 'arguments'
        } but got ${provided}.
        Use \`help ${command.name}\` to see usage info.`,
      ),
    );
    return null;
  }

  return args;
}
<file_sep>import { CmdCategory } from '@prisma/client';
import { getSdk } from '../../generated/anilist';
import { Command } from '../../types/command';
import { charInfoEmbed } from './embeds/charInfoEmbed';
import { sendNotFoundError } from './embeds/errors';
import { client } from './graphql/aniListClient';

const char = new Command({
  name: 'char',
  aliases: ['character'],
  args: [{ name: 'character', multi: true }],
  category: CmdCategory.UTILITY,
  description: 'Search for an animanga character.',

  async execute(message, args) {
    const { channel } = message;
    const charName = args.join(' ');

    const sdk = getSdk(client);
    sdk
      .searchChar({ charName })
      .then((result) => {
        if (!result.Character) {
          sendNotFoundError(charName, channel);
          return;
        }
        const { name, image, age, gender, description, dateOfBirth, siteUrl } =
          result.Character;

        channel.send(
          charInfoEmbed({
            englishName: name?.full,
            japName: name?.native,
            image: image?.large,
            gender,
            description,
            age,
            dateOfBirth,
            siteUrl,
          }),
        );
      })
      .catch((err) => {
        console.error(err);
        sendNotFoundError(charName, channel);
      });
  },
});

export default char;
<file_sep>import { shuffle } from '../../../lib/shuffle';

export const genCardsString = (chars: string[]): string => {
  // helper function to produce alphabet emojis
  let generated = '';
  chars.forEach((char) => {
    generated += `:regional_indicator_${char}: `;
  });
  return generated;
};

export const genInitialCards = (size: number) => {
  const allChars: string[] = [];
  for (let i = 0; i < 26; i += 1) {
    allChars.push(String.fromCharCode(i + 97));
  }
  const shuffled = shuffle(allChars);
  return {
    p1Cards: shuffled.slice(0, size),
    p2Cards: shuffled.slice(size, 2 * size),
    startingChar: shuffled[2 * size + 1],
  };
};
<file_sep>import { Shiritori } from './shiritori';

export const shiritoriGame: Readonly<Shiritori> = new Shiritori();
<file_sep>import type { Collection, Snowflake, User } from 'discord.js';
import { GameState } from '../../../types/gameState';

// TODO game timeout on inactivity

interface ShiritoriState extends GameState {
  p1: User;
  p2: User;
  cards: Collection<Snowflake, string[]>;
  startingChar: string;
  minLen: number;
}

export type { ShiritoriState };
<file_sep>import { CmdCategory } from '@prisma/client';
import { userProvider } from '../../data/providers/userProvider';
import { lightErrorEmbed, sendNotInGuild } from '../../shared/embeds';
import { Command } from '../../types/command';
import { MAX_TAKE } from './utils/defaults';
import { sendExceededMaxTake, sendTop } from './utils/embeds';

const top = new Command({
  name: 'top',
  args: [{ name: 'take', optional: true, multi: false }],
  category: CmdCategory.CURRENCY,
  description: 'Hunt down the richest in this server.',
  aliases: ['richest'],

  async execute(message, args) {
    const { channel, guild } = message;
    if (!guild) {
      sendNotInGuild(channel);
      return;
    }

    let take;
    const [count] = args;
    if (count) {
      const _take = parseInt(count, 10);
      if (Number.isNaN(_take)) {
        channel.send(lightErrorEmbed('Gimme a number yo.'));
        return;
      }
      if (_take > MAX_TAKE) {
        sendExceededMaxTake(channel);
        return;
      }
      take = _take;
    }

    const guildMembers = (await guild.members.fetch()).map(
      (member) => member.user,
    );

    userProvider.getTopRibbonsForUsers(guildMembers, take).then((res) =>
      sendTop(channel, res, {
        locale: guild.name,
        thumbnail: guild.iconURL({ dynamic: true, size: 128 }),
      }),
    );
  },
});

export default top;
<file_sep>import { Balloon } from './balloon';

export const balloonGame: Readonly<Balloon> = new Balloon();
<file_sep>import { CmdCategory } from '.prisma/client';
import { blindUnblock } from '../../games/utils/manageState';
import { lightErrorEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';

const stopGame = new Command({
  name: 'stop-game',
  aliases: ['sg'],
  args: [],
  category: CmdCategory.GAMES,
  description: 'Exit from the current game in session.',
  execute: async (ctx) => {
    const { channel, author } = ctx;
    blindUnblock(ctx).then(
      () => {
        channel.send(
          lightErrorEmbed(`**${author.username}** has stopped the game.`),
        );
      },
      (err) => channel.send(lightErrorEmbed(err)),
    );
  },
});

export default stopGame;
<file_sep>![](https://i.imgur.com/qNZOPv2.png)

<p>
  <a><img alt="ci" src="https://github.com/shinomiya-corp/chika/actions/workflows/ci.yml/badge.svg"/></a>
  <a href="https://codecov.io/gh/shinomiya-corp/chika"> <img src="https://codecov.io/gh/shinomiya-corp/chika/branch/dev/graph/badge.svg?token=<KEY>"/> </a>
  <a href="https://codeclimate.com/github/shinomiya-corp/chika-bot/maintainability"><img src="https://api.codeclimate.com/v1/badges/b8793d37cbbbde8f1e96/maintainability" /></a>
  <a href="https://www.chikawara.xyz"><img alt="Website" src="https://img.shields.io/website?url=https%3A%2F%2Fwww.chikawara.xyz"></a>
  <a><img alt="GitHub" src="https://img.shields.io/github/license/shinomiya-corp/chika"></a>
  <a href="https://discord.com/api/oauth2/authorize?client_id=843481025459519528&permissions=540018688&scope=bot"><img alt="Invite" src="https://img.shields.io/static/v1?logo=discord&label=invite&message=Chika&color=f2d5da"></a>
<p/>

# discord.js bot for Chika

Hayasaka is best girl, so it puzzles myself why I chose Chika as a template for the bot. But we've gone too far to turn back now. If I started this project from scratch today, I'd use a tool like [Nx](https://github.com/nrwl/nx) and put everything in a mono-repo. But I was dumb and divided this project into: the bot itself (this page), [server](https://github.com/shinomiya-corp/chika-server), [landing page](https://github.com/immanuelhume/chika-next), [dashboard](https://github.com/immanuelhume/chika-dashboard), and even a repo just for a freakin prisma [schema file and migrations](https://github.com/shinomiya-corp/chika-schema).

## Features

- 🎮 Games from the manga (Shiritori and Balloon(?))
  - More are coming soon<sup>TM</sup>
- 🎧 Simple music streaming
- 🦜 An unintelligent AI chatbot for Kaguya and Chika
  - I don't know anything about ML but hey it kinda works
- 💰 Global currency system
- ...and some others probably, see the full list [here](https://www.chikawara.xyz/commands)

## Run Locally

Clone the project

```bash
  git clone https://github.com/shinomiya-corp/chika.git
```

Install dependencies

```bash
  cd chika && yarn
```

Build and run (requires node v14.16.1 for music to work, idk why lol)

```bash
  yarn tsc && node dist/index.js
```

## Environment Variables

To run this project, you'll need to add the following environment variables to your .env file

`APP_TOKEN`, `BOT_USER_ID` (from Discord's developer portal)

`DATABASE_URL`, `REDISCLOUD_URL` (pointing to a postgres and redis instance)

`HUGGING_FACE_CHIKA`, `HUGGING_FACE_CHIKA_KEY`, `HUGGING_FACE_KAGUYA`, `HUGGING_FACE_KAGUYA_KEY` (from hosting the ML stuff on huggingface.co)

`ANILIST_SCHEMA` (it's just https://graphql.anilist.co)

## Roadmap

- Web dashboard in NextJS 🥢
- Integrate [uwu](https://github.com/Daniel-Liu-c0deb0t/uwu) into a command
- 30% test cov pls

## Screenshots

Here are some bruh moments

![](https://i.imgur.com/f04jOgr.png)

![](https://i.imgur.com/m5F6ows.png)

![](https://i.imgur.com/UQZxD0E.png)

## Feedback

If you have any feedback or spot bugs, please raise an issue on GitHub.
<file_sep>import { baseEmbed } from '../../../shared/embeds';

export const dashboardLink = baseEmbed().setDescription(
  '[Here you go!](https://dashboard.chikawara.xyz)',
);
<file_sep>import Discord, { Collection } from 'discord.js';
import type { AudioUtils } from '../types/queue';

export const initialClientCache: Discord.Client['cache'] = {
  audioUtils: new Collection<string, AudioUtils>(),
  inGameStates: new Collection<string, string>(),
};
<file_sep>import type { User } from 'discord.js';
import { ribbon_emoji } from '../../../shared/assets';
import { baseEmbed } from '../../../shared/embeds';
import { GenericChannel } from '../../../types/command';

export const sendPopped = (
  channel: GenericChannel,
  info: { popper: User; isBankrupt: boolean; winAmt: number },
) => {
  const { popper, isBankrupt, winAmt } = info;
  channel.send(
    baseEmbed()
      .setTitle(`Boom, yo! :boom:`)
      .setDescription(
        `
        The balloon bursts in **${popper.username}**'s face!
  
        ${
          isBankrupt
            ? `**${popper.username}** is bankrupt!\nEveryone else receives **+ ${winAmt}** ${ribbon_emoji}, sponsored by the Chika Bank.`
            : `**${popper.username}** pays everyone else **${winAmt}** ${ribbon_emoji}.`
        }
        `,
      ),
  );
};
<file_sep>import { CmdCategory } from '@prisma/client';
import ytpl from 'ytpl';
import { redisQueue } from '../../data/redisClient';
import { cryingEmbed, withAuthorEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';
import { sendMusicOnlyInGuild, toUrlString } from './utils/embeds';
import { parsePlaylist } from './utils/youtube';

const addPlaylist = new Command({
  name: 'add-playlist',
  aliases: ['ap'],
  category: CmdCategory.MUSIC,
  description: 'Add a YouTube playlist to the queue.',
  args: [{ name: 'playlist_url' }],

  async execute(message, args) {
    const { guild, channel, author } = message;
    if (!guild) {
      sendMusicOnlyInGuild(channel);
      return;
    }
    const [playlistURL] = args;
    ytpl(playlistURL)
      .then((res) => {
        const [playlistMetadata, videos] = parsePlaylist(res);
        redisQueue.rpush(
          guild.id,
          ...videos.map((video) => JSON.stringify(video)),
        );
        channel.send(
          withAuthorEmbed(author)
            .setTitle('Added Playlist')
            .setDescription(
              `${toUrlString(
                playlistMetadata.title,
                playlistMetadata.url,
                40,
              )}`,
            )
            .setThumbnail(playlistMetadata.thumbnailURL),
        );
      })
      .catch(() =>
        channel.send(
          cryingEmbed()
            .setTitle('Sorry...')
            .setDescription(
              `I couldn't find a playlist at ${toUrlString(
                playlistURL,
                playlistURL,
                40,
              )}`,
            )
            .addField('\u200b', 'It might be a private playlist?'),
        ),
      );
  },
});

export default addPlaylist;
<file_sep>import type { Message } from 'discord.js';
import { chika_beating_yu_gif, white_check_mark } from '../../../shared/assets';
import { baseEmbed } from '../../../shared/embeds';
import { filterMessage } from '../../../lib/validateMessages';
import { isGameActive, unblock } from '../../utils/manageState';
import type { Shiritori } from '../shiritori';
import { checkWord } from './checkWord';
import { shiritoriPlayerCardsEmbed } from './embeds';
import type { ShiritoriState } from './types';

export const createOnceShiritoriListener = (
  state: ShiritoriState,
  game: Readonly<Shiritori>,
) => {
  const shiritoriListener = async (message: Message) => {
    const { author, content, channel, client } = message;

    const onRejectListener = createOnceShiritoriListener(state, game);
    const reject = () => client.once('message', onRejectListener);

    if (
      !filterMessage(message, {
        channelId: state.channelId,
        authors: [state.p1, state.p2],
        content: new RegExp(`^${state.startingChar}`, 'i'),
        minLen: state.minLen,
      })
    ) {
      reject();
      return;
    }
    if (!isGameActive(game, message)) return;

    const playerCards = state.cards.get(author.id)!;

    const lastChar = content[content.length - 1];
    if (!playerCards.includes(content[content.length - 1])) {
      reject();
      return;
    }

    const isValidWord = await checkWord(content);
    if (!isValidWord) {
      reject();
      return;
    }

    channel.send(`I accept **${content}**! ${white_check_mark}`);
    playerCards.splice(playerCards.indexOf(lastChar), 1); // it's valid, pop that word out

    if (playerCards.length === 0) {
      channel.send(
        baseEmbed()
          .setDescription(
            `**${author.username}** defeats **${
              author.id === state.p1.id ? state.p2.username : state.p1.username
            }!**`,
          )
          .setImage(chika_beating_yu_gif),
      );
      unblock(game, message);
      return;
    }

    client.once('message', createOnceShiritoriListener(state, game));
    channel
      .send(shiritoriPlayerCardsEmbed(state))
      .then(() => channel.send(`:regional_indicator_${lastChar}:`));

    state.startingChar = lastChar;
  };

  return shiritoriListener;
};
<file_sep>import { GraphQLClient } from 'graphql-request';
import * as Dom from 'graphql-request/dist/types.dom';
import gql from 'graphql-tag';
export type Maybe<T> = T | null;
export type Exact<T extends { [key: string]: unknown }> = {
  [K in keyof T]: T[K];
};
export type MakeOptional<T, K extends keyof T> = Omit<T, K> &
  { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> &
  { [SubKey in K]: Maybe<T[SubKey]> };
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
  ID: string;
  String: string;
  Boolean: boolean;
  Int: number;
  Float: number;
  /** ISO 3166-1 alpha-2 country code */
  CountryCode: any;
  /** 8 digit long date integer (YYYYMMDD). Unknown dates represented by 0. E.g. 2016: 20160000, May 1976: 19760500 */
  FuzzyDateInt: any;
  Json: any;
};

/** Notification for when a activity is liked */
export type ActivityLikeNotification = {
  __typename?: 'ActivityLikeNotification';
  /** The id of the Notification */
  id: Scalars['Int'];
  /** The id of the user who liked to the activity */
  userId: Scalars['Int'];
  /** The type of notification */
  type?: Maybe<NotificationType>;
  /** The id of the activity which was liked */
  activityId: Scalars['Int'];
  /** The notification context text */
  context?: Maybe<Scalars['String']>;
  /** The time the notification was created at */
  createdAt?: Maybe<Scalars['Int']>;
  /** The liked activity */
  activity?: Maybe<ActivityUnion>;
  /** The user who liked the activity */
  user?: Maybe<User>;
};

/** Notification for when authenticated user is @ mentioned in activity or reply */
export type ActivityMentionNotification = {
  __typename?: 'ActivityMentionNotification';
  /** The id of the Notification */
  id: Scalars['Int'];
  /** The id of the user who mentioned the authenticated user */
  userId: Scalars['Int'];
  /** The type of notification */
  type?: Maybe<NotificationType>;
  /** The id of the activity where mentioned */
  activityId: Scalars['Int'];
  /** The notification context text */
  context?: Maybe<Scalars['String']>;
  /** The time the notification was created at */
  createdAt?: Maybe<Scalars['Int']>;
  /** The liked activity */
  activity?: Maybe<ActivityUnion>;
  /** The user who mentioned the authenticated user */
  user?: Maybe<User>;
};

/** Notification for when a user is send an activity message */
export type ActivityMessageNotification = {
  __typename?: 'ActivityMessageNotification';
  /** The id of the Notification */
  id: Scalars['Int'];
  /** The if of the user who send the message */
  userId: Scalars['Int'];
  /** The type of notification */
  type?: Maybe<NotificationType>;
  /** The id of the activity message */
  activityId: Scalars['Int'];
  /** The notification context text */
  context?: Maybe<Scalars['String']>;
  /** The time the notification was created at */
  createdAt?: Maybe<Scalars['Int']>;
  /** The message activity */
  message?: Maybe<MessageActivity>;
  /** The user who sent the message */
  user?: Maybe<User>;
};

/** Replay to an activity item */
export type ActivityReply = {
  __typename?: 'ActivityReply';
  /** The id of the reply */
  id: Scalars['Int'];
  /** The id of the replies creator */
  userId?: Maybe<Scalars['Int']>;
  /** The id of the parent activity */
  activityId?: Maybe<Scalars['Int']>;
  /** The reply text */
  text?: Maybe<Scalars['String']>;
  /** The amount of likes the reply has */
  likeCount: Scalars['Int'];
  /** If the currently authenticated user liked the reply */
  isLiked?: Maybe<Scalars['Boolean']>;
  /** The time the reply was created at */
  createdAt: Scalars['Int'];
  /** The user who created reply */
  user?: Maybe<User>;
  /** The users who liked the reply */
  likes?: Maybe<Array<Maybe<User>>>;
};

/** Replay to an activity item */
export type ActivityReplyTextArgs = {
  asHtml?: Maybe<Scalars['Boolean']>;
};

/** Notification for when a activity reply is liked */
export type ActivityReplyLikeNotification = {
  __typename?: 'ActivityReplyLikeNotification';
  /** The id of the Notification */
  id: Scalars['Int'];
  /** The id of the user who liked to the activity reply */
  userId: Scalars['Int'];
  /** The type of notification */
  type?: Maybe<NotificationType>;
  /** The id of the activity where the reply which was liked */
  activityId: Scalars['Int'];
  /** The notification context text */
  context?: Maybe<Scalars['String']>;
  /** The time the notification was created at */
  createdAt?: Maybe<Scalars['Int']>;
  /** The liked activity */
  activity?: Maybe<ActivityUnion>;
  /** The user who liked the activity reply */
  user?: Maybe<User>;
};

/** Notification for when a user replies to the authenticated users activity */
export type ActivityReplyNotification = {
  __typename?: 'ActivityReplyNotification';
  /** The id of the Notification */
  id: Scalars['Int'];
  /** The id of the user who replied to the activity */
  userId: Scalars['Int'];
  /** The type of notification */
  type?: Maybe<NotificationType>;
  /** The id of the activity which was replied too */
  activityId: Scalars['Int'];
  /** The notification context text */
  context?: Maybe<Scalars['String']>;
  /** The time the notification was created at */
  createdAt?: Maybe<Scalars['Int']>;
  /** The liked activity */
  activity?: Maybe<ActivityUnion>;
  /** The user who replied to the activity */
  user?: Maybe<User>;
};

/** Notification for when a user replies to activity the authenticated user has replied to */
export type ActivityReplySubscribedNotification = {
  __typename?: 'ActivityReplySubscribedNotification';
  /** The id of the Notification */
  id: Scalars['Int'];
  /** The id of the user who replied to the activity */
  userId: Scalars['Int'];
  /** The type of notification */
  type?: Maybe<NotificationType>;
  /** The id of the activity which was replied too */
  activityId: Scalars['Int'];
  /** The notification context text */
  context?: Maybe<Scalars['String']>;
  /** The time the notification was created at */
  createdAt?: Maybe<Scalars['Int']>;
  /** The liked activity */
  activity?: Maybe<ActivityUnion>;
  /** The user who replied to the activity */
  user?: Maybe<User>;
};

/** Activity sort enums */
export enum ActivitySort {
  Id = 'ID',
  IdDesc = 'ID_DESC',
}

/** Activity type enum. */
export enum ActivityType {
  /** A text activity */
  Text = 'TEXT',
  /** A anime list update activity */
  AnimeList = 'ANIME_LIST',
  /** A manga list update activity */
  MangaList = 'MANGA_LIST',
  /** A text message activity sent to another user */
  Message = 'MESSAGE',
  /** Anime & Manga list update, only used in query arguments */
  MediaList = 'MEDIA_LIST',
}

/** Activity union type */
export type ActivityUnion = TextActivity | ListActivity | MessageActivity;

/** Notification for when an episode of anime airs */
export type AiringNotification = {
  __typename?: 'AiringNotification';
  /** The id of the Notification */
  id: Scalars['Int'];
  /** The type of notification */
  type?: Maybe<NotificationType>;
  /** The id of the aired anime */
  animeId: Scalars['Int'];
  /** The episode number that just aired */
  episode: Scalars['Int'];
  /** The notification context text */
  contexts?: Maybe<Array<Maybe<Scalars['String']>>>;
  /** The time the notification was created at */
  createdAt?: Maybe<Scalars['Int']>;
  /** The associated media of the airing schedule */
  media?: Maybe<Media>;
};

/** Score & Watcher stats for airing anime by episode and mid-week */
export type AiringProgression = {
  __typename?: 'AiringProgression';
  /** The episode the stats were recorded at. .5 is the mid point between 2 episodes airing dates. */
  episode?: Maybe<Scalars['Float']>;
  /** The average score for the media */
  score?: Maybe<Scalars['Float']>;
  /** The amount of users watching the anime */
  watching?: Maybe<Scalars['Int']>;
};

/** Media Airing Schedule. NOTE: We only aim to guarantee that FUTURE airing data is present and accurate. */
export type AiringSchedule = {
  __typename?: 'AiringSchedule';
  /** The id of the airing schedule item */
  id: Scalars['Int'];
  /** The time the episode airs at */
  airingAt: Scalars['Int'];
  /** Seconds until episode starts airing */
  timeUntilAiring: Scalars['Int'];
  /** The airing episode number */
  episode: Scalars['Int'];
  /** The associate media id of the airing episode */
  mediaId: Scalars['Int'];
  /** The associate media of the airing episode */
  media?: Maybe<Media>;
};

export type AiringScheduleConnection = {
  __typename?: 'AiringScheduleConnection';
  edges?: Maybe<Array<Maybe<AiringScheduleEdge>>>;
  nodes?: Maybe<Array<Maybe<AiringSchedule>>>;
  /** The pagination information */
  pageInfo?: Maybe<PageInfo>;
};

/** AiringSchedule connection edge */
export type AiringScheduleEdge = {
  __typename?: 'AiringScheduleEdge';
  node?: Maybe<AiringSchedule>;
  /** The id of the connection */
  id?: Maybe<Scalars['Int']>;
};

export type AiringScheduleInput = {
  airingAt?: Maybe<Scalars['Int']>;
  episode?: Maybe<Scalars['Int']>;
  timeUntilAiring?: Maybe<Scalars['Int']>;
};

/** Airing schedule sort enums */
export enum AiringSort {
  Id = 'ID',
  IdDesc = 'ID_DESC',
  MediaId = 'MEDIA_ID',
  MediaIdDesc = 'MEDIA_ID_DESC',
  Time = 'TIME',
  TimeDesc = 'TIME_DESC',
  Episode = 'EPISODE',
  EpisodeDesc = 'EPISODE_DESC',
}

export type AniChartHighlightInput = {
  mediaId?: Maybe<Scalars['Int']>;
  highlight?: Maybe<Scalars['String']>;
};

export type AniChartUser = {
  __typename?: 'AniChartUser';
  user?: Maybe<User>;
  settings?: Maybe<Scalars['Json']>;
  highlights?: Maybe<Scalars['Json']>;
};

/** A character that features in an anime or manga */
export type Character = {
  __typename?: 'Character';
  /** The id of the character */
  id: Scalars['Int'];
  /** The names of the character */
  name?: Maybe<CharacterName>;
  /** Character images */
  image?: Maybe<CharacterImage>;
  /** A general description of the character */
  description?: Maybe<Scalars['String']>;
  /** The character's gender. Usually Male, Female, or Non-binary but can be any string. */
  gender?: Maybe<Scalars['String']>;
  /** The character's birth date */
  dateOfBirth?: Maybe<FuzzyDate>;
  /** The character's age. Note this is a string, not an int, it may contain further text and additional ages. */
  age?: Maybe<Scalars['String']>;
  /** If the character is marked as favourite by the currently authenticated user */
  isFavourite: Scalars['Boolean'];
  /** If the character is blocked from being added to favourites */
  isFavouriteBlocked: Scalars['Boolean'];
  /** The url for the character page on the AniList website */
  siteUrl?: Maybe<Scalars['String']>;
  /** Media that includes the character */
  media?: Maybe<MediaConnection>;
  /** @deprecated No data available */
  updatedAt?: Maybe<Scalars['Int']>;
  /** The amount of user's who have favourited the character */
  favourites?: Maybe<Scalars['Int']>;
  /** Notes for site moderators */
  modNotes?: Maybe<Scalars['String']>;
};

/** A character that features in an anime or manga */
export type CharacterDescriptionArgs = {
  asHtml?: Maybe<Scalars['Boolean']>;
};

/** A character that features in an anime or manga */
export type CharacterMediaArgs = {
  sort?: Maybe<Array<Maybe<MediaSort>>>;
  type?: Maybe<MediaType>;
  onList?: Maybe<Scalars['Boolean']>;
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

export type CharacterConnection = {
  __typename?: 'CharacterConnection';
  edges?: Maybe<Array<Maybe<CharacterEdge>>>;
  nodes?: Maybe<Array<Maybe<Character>>>;
  /** The pagination information */
  pageInfo?: Maybe<PageInfo>;
};

/** Character connection edge */
export type CharacterEdge = {
  __typename?: 'CharacterEdge';
  node?: Maybe<Character>;
  /** The id of the connection */
  id?: Maybe<Scalars['Int']>;
  /** The characters role in the media */
  role?: Maybe<CharacterRole>;
  /** Media specific character name */
  name?: Maybe<Scalars['String']>;
  /** The voice actors of the character */
  voiceActors?: Maybe<Array<Maybe<Staff>>>;
  /** The voice actors of the character with role date */
  voiceActorRoles?: Maybe<Array<Maybe<StaffRoleType>>>;
  /** The media the character is in */
  media?: Maybe<Array<Maybe<Media>>>;
  /** The order the character should be displayed from the users favourites */
  favouriteOrder?: Maybe<Scalars['Int']>;
};

/** Character connection edge */
export type CharacterEdgeVoiceActorsArgs = {
  language?: Maybe<StaffLanguage>;
  sort?: Maybe<Array<Maybe<StaffSort>>>;
};

/** Character connection edge */
export type CharacterEdgeVoiceActorRolesArgs = {
  language?: Maybe<StaffLanguage>;
  sort?: Maybe<Array<Maybe<StaffSort>>>;
};

export type CharacterImage = {
  __typename?: 'CharacterImage';
  /** The character's image of media at its largest size */
  large?: Maybe<Scalars['String']>;
  /** The character's image of media at medium size */
  medium?: Maybe<Scalars['String']>;
};

/** The names of the character */
export type CharacterName = {
  __typename?: 'CharacterName';
  /** The character's given name */
  first?: Maybe<Scalars['String']>;
  /** The character's middle name */
  middle?: Maybe<Scalars['String']>;
  /** The character's surname */
  last?: Maybe<Scalars['String']>;
  /** The character's first and last name */
  full?: Maybe<Scalars['String']>;
  /** The character's full name in their native language */
  native?: Maybe<Scalars['String']>;
  /** Other names the character might be referred to as */
  alternative?: Maybe<Array<Maybe<Scalars['String']>>>;
  /** Other names the character might be referred to as but are spoilers */
  alternativeSpoiler?: Maybe<Array<Maybe<Scalars['String']>>>;
  /** The currently authenticated users preferred name language. Default romaji for non-authenticated */
  userPreferred?: Maybe<Scalars['String']>;
};

/** The names of the character */
export type CharacterNameInput = {
  /** The character's given name */
  first?: Maybe<Scalars['String']>;
  /** The character's middle name */
  middle?: Maybe<Scalars['String']>;
  /** The character's surname */
  last?: Maybe<Scalars['String']>;
  /** The character's full name in their native language */
  native?: Maybe<Scalars['String']>;
  /** Other names the character might be referred by */
  alternative?: Maybe<Array<Maybe<Scalars['String']>>>;
  /** Other names the character might be referred to as but are spoilers */
  alternativeSpoiler?: Maybe<Array<Maybe<Scalars['String']>>>;
};

/** The role the character plays in the media */
export enum CharacterRole {
  /** A primary character role in the media */
  Main = 'MAIN',
  /** A supporting character role in the media */
  Supporting = 'SUPPORTING',
  /** A background character in the media */
  Background = 'BACKGROUND',
}

/** Character sort enums */
export enum CharacterSort {
  Id = 'ID',
  IdDesc = 'ID_DESC',
  Role = 'ROLE',
  RoleDesc = 'ROLE_DESC',
  SearchMatch = 'SEARCH_MATCH',
  Favourites = 'FAVOURITES',
  FavouritesDesc = 'FAVOURITES_DESC',
  /** Order manually decided by moderators */
  Relevance = 'RELEVANCE',
}

/** A submission for a character that features in an anime or manga */
export type CharacterSubmission = {
  __typename?: 'CharacterSubmission';
  /** The id of the submission */
  id: Scalars['Int'];
  /** Character that the submission is referencing */
  character?: Maybe<Character>;
  /** The character submission changes */
  submission?: Maybe<Character>;
  /** Submitter for the submission */
  submitter?: Maybe<User>;
  /** Status of the submission */
  status?: Maybe<SubmissionStatus>;
  /** Inner details of submission status */
  notes?: Maybe<Scalars['String']>;
  source?: Maybe<Scalars['String']>;
  createdAt?: Maybe<Scalars['Int']>;
};

export type CharacterSubmissionConnection = {
  __typename?: 'CharacterSubmissionConnection';
  edges?: Maybe<Array<Maybe<CharacterSubmissionEdge>>>;
  nodes?: Maybe<Array<Maybe<CharacterSubmission>>>;
  /** The pagination information */
  pageInfo?: Maybe<PageInfo>;
};

/** CharacterSubmission connection edge */
export type CharacterSubmissionEdge = {
  __typename?: 'CharacterSubmissionEdge';
  node?: Maybe<CharacterSubmission>;
  /** The characters role in the media */
  role?: Maybe<CharacterRole>;
  /** The voice actors of the character */
  voiceActors?: Maybe<Array<Maybe<Staff>>>;
  /** The submitted voice actors of the character */
  submittedVoiceActors?: Maybe<Array<Maybe<StaffSubmission>>>;
};

/** Deleted data type */
export type Deleted = {
  __typename?: 'Deleted';
  /** If an item has been successfully deleted */
  deleted?: Maybe<Scalars['Boolean']>;
};

/** User's favourite anime, manga, characters, staff & studios */
export type Favourites = {
  __typename?: 'Favourites';
  /** Favourite anime */
  anime?: Maybe<MediaConnection>;
  /** Favourite manga */
  manga?: Maybe<MediaConnection>;
  /** Favourite characters */
  characters?: Maybe<CharacterConnection>;
  /** Favourite staff */
  staff?: Maybe<StaffConnection>;
  /** Favourite studios */
  studios?: Maybe<StudioConnection>;
};

/** User's favourite anime, manga, characters, staff & studios */
export type FavouritesAnimeArgs = {
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

/** User's favourite anime, manga, characters, staff & studios */
export type FavouritesMangaArgs = {
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

/** User's favourite anime, manga, characters, staff & studios */
export type FavouritesCharactersArgs = {
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

/** User's favourite anime, manga, characters, staff & studios */
export type FavouritesStaffArgs = {
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

/** User's favourite anime, manga, characters, staff & studios */
export type FavouritesStudiosArgs = {
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

/** Notification for when the authenticated user is followed by another user */
export type FollowingNotification = {
  __typename?: 'FollowingNotification';
  /** The id of the Notification */
  id: Scalars['Int'];
  /** The id of the user who followed the authenticated user */
  userId: Scalars['Int'];
  /** The type of notification */
  type?: Maybe<NotificationType>;
  /** The notification context text */
  context?: Maybe<Scalars['String']>;
  /** The time the notification was created at */
  createdAt?: Maybe<Scalars['Int']>;
  /** The liked activity */
  user?: Maybe<User>;
};

/** User's format statistics */
export type FormatStats = {
  __typename?: 'FormatStats';
  format?: Maybe<MediaFormat>;
  amount?: Maybe<Scalars['Int']>;
};

/** Date object that allows for incomplete date values (fuzzy) */
export type FuzzyDate = {
  __typename?: 'FuzzyDate';
  /** Numeric Year (2017) */
  year?: Maybe<Scalars['Int']>;
  /** Numeric Month (3) */
  month?: Maybe<Scalars['Int']>;
  /** Numeric Day (24) */
  day?: Maybe<Scalars['Int']>;
};

/** Date object that allows for incomplete date values (fuzzy) */
export type FuzzyDateInput = {
  /** Numeric Year (2017) */
  year?: Maybe<Scalars['Int']>;
  /** Numeric Month (3) */
  month?: Maybe<Scalars['Int']>;
  /** Numeric Day (24) */
  day?: Maybe<Scalars['Int']>;
};

/** User's genre statistics */
export type GenreStats = {
  __typename?: 'GenreStats';
  genre?: Maybe<Scalars['String']>;
  amount?: Maybe<Scalars['Int']>;
  meanScore?: Maybe<Scalars['Int']>;
  /** The amount of time in minutes the genre has been watched by the user */
  timeWatched?: Maybe<Scalars['Int']>;
};

/** Page of data (Used for internal use only) */
export type InternalPage = {
  __typename?: 'InternalPage';
  mediaSubmissions?: Maybe<Array<Maybe<MediaSubmission>>>;
  characterSubmissions?: Maybe<Array<Maybe<CharacterSubmission>>>;
  staffSubmissions?: Maybe<Array<Maybe<StaffSubmission>>>;
  revisionHistory?: Maybe<Array<Maybe<RevisionHistory>>>;
  reports?: Maybe<Array<Maybe<Report>>>;
  modActions?: Maybe<Array<Maybe<ModAction>>>;
  /** The pagination information */
  pageInfo?: Maybe<PageInfo>;
  users?: Maybe<Array<Maybe<User>>>;
  media?: Maybe<Array<Maybe<Media>>>;
  characters?: Maybe<Array<Maybe<Character>>>;
  staff?: Maybe<Array<Maybe<Staff>>>;
  studios?: Maybe<Array<Maybe<Studio>>>;
  mediaList?: Maybe<Array<Maybe<MediaList>>>;
  airingSchedules?: Maybe<Array<Maybe<AiringSchedule>>>;
  mediaTrends?: Maybe<Array<Maybe<MediaTrend>>>;
  notifications?: Maybe<Array<Maybe<NotificationUnion>>>;
  followers?: Maybe<Array<Maybe<User>>>;
  following?: Maybe<Array<Maybe<User>>>;
  activities?: Maybe<Array<Maybe<ActivityUnion>>>;
  activityReplies?: Maybe<Array<Maybe<ActivityReply>>>;
  threads?: Maybe<Array<Maybe<Thread>>>;
  threadComments?: Maybe<Array<Maybe<ThreadComment>>>;
  reviews?: Maybe<Array<Maybe<Review>>>;
  recommendations?: Maybe<Array<Maybe<Recommendation>>>;
  likes?: Maybe<Array<Maybe<User>>>;
};

/** Page of data (Used for internal use only) */
export type InternalPageMediaSubmissionsArgs = {
  mediaId?: Maybe<Scalars['Int']>;
  submissionId?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  status?: Maybe<SubmissionStatus>;
  type?: Maybe<MediaType>;
  sort?: Maybe<Array<Maybe<SubmissionSort>>>;
};

/** Page of data (Used for internal use only) */
export type InternalPageCharacterSubmissionsArgs = {
  characterId?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  status?: Maybe<SubmissionStatus>;
  sort?: Maybe<Array<Maybe<SubmissionSort>>>;
};

/** Page of data (Used for internal use only) */
export type InternalPageStaffSubmissionsArgs = {
  staffId?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  status?: Maybe<SubmissionStatus>;
  sort?: Maybe<Array<Maybe<SubmissionSort>>>;
};

/** Page of data (Used for internal use only) */
export type InternalPageRevisionHistoryArgs = {
  userId?: Maybe<Scalars['Int']>;
  mediaId?: Maybe<Scalars['Int']>;
  characterId?: Maybe<Scalars['Int']>;
  staffId?: Maybe<Scalars['Int']>;
  studioId?: Maybe<Scalars['Int']>;
};

/** Page of data (Used for internal use only) */
export type InternalPageReportsArgs = {
  reporterId?: Maybe<Scalars['Int']>;
  reportedId?: Maybe<Scalars['Int']>;
};

/** Page of data (Used for internal use only) */
export type InternalPageModActionsArgs = {
  userId?: Maybe<Scalars['Int']>;
  modId?: Maybe<Scalars['Int']>;
};

/** Page of data (Used for internal use only) */
export type InternalPageUsersArgs = {
  id?: Maybe<Scalars['Int']>;
  name?: Maybe<Scalars['String']>;
  isModerator?: Maybe<Scalars['Boolean']>;
  search?: Maybe<Scalars['String']>;
  sort?: Maybe<Array<Maybe<UserSort>>>;
};

/** Page of data (Used for internal use only) */
export type InternalPageMediaArgs = {
  id?: Maybe<Scalars['Int']>;
  idMal?: Maybe<Scalars['Int']>;
  startDate?: Maybe<Scalars['FuzzyDateInt']>;
  endDate?: Maybe<Scalars['FuzzyDateInt']>;
  season?: Maybe<MediaSeason>;
  seasonYear?: Maybe<Scalars['Int']>;
  type?: Maybe<MediaType>;
  format?: Maybe<MediaFormat>;
  status?: Maybe<MediaStatus>;
  episodes?: Maybe<Scalars['Int']>;
  duration?: Maybe<Scalars['Int']>;
  chapters?: Maybe<Scalars['Int']>;
  volumes?: Maybe<Scalars['Int']>;
  isAdult?: Maybe<Scalars['Boolean']>;
  genre?: Maybe<Scalars['String']>;
  tag?: Maybe<Scalars['String']>;
  minimumTagRank?: Maybe<Scalars['Int']>;
  tagCategory?: Maybe<Scalars['String']>;
  onList?: Maybe<Scalars['Boolean']>;
  licensedBy?: Maybe<Scalars['String']>;
  averageScore?: Maybe<Scalars['Int']>;
  popularity?: Maybe<Scalars['Int']>;
  source?: Maybe<MediaSource>;
  countryOfOrigin?: Maybe<Scalars['CountryCode']>;
  search?: Maybe<Scalars['String']>;
  id_not?: Maybe<Scalars['Int']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  id_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  idMal_not?: Maybe<Scalars['Int']>;
  idMal_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  idMal_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  startDate_greater?: Maybe<Scalars['FuzzyDateInt']>;
  startDate_lesser?: Maybe<Scalars['FuzzyDateInt']>;
  startDate_like?: Maybe<Scalars['String']>;
  endDate_greater?: Maybe<Scalars['FuzzyDateInt']>;
  endDate_lesser?: Maybe<Scalars['FuzzyDateInt']>;
  endDate_like?: Maybe<Scalars['String']>;
  format_in?: Maybe<Array<Maybe<MediaFormat>>>;
  format_not?: Maybe<MediaFormat>;
  format_not_in?: Maybe<Array<Maybe<MediaFormat>>>;
  status_in?: Maybe<Array<Maybe<MediaStatus>>>;
  status_not?: Maybe<MediaStatus>;
  status_not_in?: Maybe<Array<Maybe<MediaStatus>>>;
  episodes_greater?: Maybe<Scalars['Int']>;
  episodes_lesser?: Maybe<Scalars['Int']>;
  duration_greater?: Maybe<Scalars['Int']>;
  duration_lesser?: Maybe<Scalars['Int']>;
  chapters_greater?: Maybe<Scalars['Int']>;
  chapters_lesser?: Maybe<Scalars['Int']>;
  volumes_greater?: Maybe<Scalars['Int']>;
  volumes_lesser?: Maybe<Scalars['Int']>;
  genre_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  genre_not_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  tag_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  tag_not_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  tagCategory_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  tagCategory_not_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  licensedBy_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  averageScore_not?: Maybe<Scalars['Int']>;
  averageScore_greater?: Maybe<Scalars['Int']>;
  averageScore_lesser?: Maybe<Scalars['Int']>;
  popularity_not?: Maybe<Scalars['Int']>;
  popularity_greater?: Maybe<Scalars['Int']>;
  popularity_lesser?: Maybe<Scalars['Int']>;
  source_in?: Maybe<Array<Maybe<MediaSource>>>;
  sort?: Maybe<Array<Maybe<MediaSort>>>;
};

/** Page of data (Used for internal use only) */
export type InternalPageCharactersArgs = {
  id?: Maybe<Scalars['Int']>;
  isBirthday?: Maybe<Scalars['Boolean']>;
  search?: Maybe<Scalars['String']>;
  id_not?: Maybe<Scalars['Int']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  id_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  sort?: Maybe<Array<Maybe<CharacterSort>>>;
};

/** Page of data (Used for internal use only) */
export type InternalPageStaffArgs = {
  id?: Maybe<Scalars['Int']>;
  isBirthday?: Maybe<Scalars['Boolean']>;
  search?: Maybe<Scalars['String']>;
  id_not?: Maybe<Scalars['Int']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  id_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  sort?: Maybe<Array<Maybe<StaffSort>>>;
};

/** Page of data (Used for internal use only) */
export type InternalPageStudiosArgs = {
  id?: Maybe<Scalars['Int']>;
  search?: Maybe<Scalars['String']>;
  id_not?: Maybe<Scalars['Int']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  id_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  sort?: Maybe<Array<Maybe<StudioSort>>>;
};

/** Page of data (Used for internal use only) */
export type InternalPageMediaListArgs = {
  id?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  userName?: Maybe<Scalars['String']>;
  type?: Maybe<MediaType>;
  status?: Maybe<MediaListStatus>;
  mediaId?: Maybe<Scalars['Int']>;
  isFollowing?: Maybe<Scalars['Boolean']>;
  notes?: Maybe<Scalars['String']>;
  startedAt?: Maybe<Scalars['FuzzyDateInt']>;
  completedAt?: Maybe<Scalars['FuzzyDateInt']>;
  compareWithAuthList?: Maybe<Scalars['Boolean']>;
  userId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  status_in?: Maybe<Array<Maybe<MediaListStatus>>>;
  status_not_in?: Maybe<Array<Maybe<MediaListStatus>>>;
  status_not?: Maybe<MediaListStatus>;
  mediaId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mediaId_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  notes_like?: Maybe<Scalars['String']>;
  startedAt_greater?: Maybe<Scalars['FuzzyDateInt']>;
  startedAt_lesser?: Maybe<Scalars['FuzzyDateInt']>;
  startedAt_like?: Maybe<Scalars['String']>;
  completedAt_greater?: Maybe<Scalars['FuzzyDateInt']>;
  completedAt_lesser?: Maybe<Scalars['FuzzyDateInt']>;
  completedAt_like?: Maybe<Scalars['String']>;
  sort?: Maybe<Array<Maybe<MediaListSort>>>;
};

/** Page of data (Used for internal use only) */
export type InternalPageAiringSchedulesArgs = {
  id?: Maybe<Scalars['Int']>;
  mediaId?: Maybe<Scalars['Int']>;
  episode?: Maybe<Scalars['Int']>;
  airingAt?: Maybe<Scalars['Int']>;
  notYetAired?: Maybe<Scalars['Boolean']>;
  id_not?: Maybe<Scalars['Int']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  id_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mediaId_not?: Maybe<Scalars['Int']>;
  mediaId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mediaId_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  episode_not?: Maybe<Scalars['Int']>;
  episode_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  episode_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  episode_greater?: Maybe<Scalars['Int']>;
  episode_lesser?: Maybe<Scalars['Int']>;
  airingAt_greater?: Maybe<Scalars['Int']>;
  airingAt_lesser?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<AiringSort>>>;
};

/** Page of data (Used for internal use only) */
export type InternalPageMediaTrendsArgs = {
  mediaId?: Maybe<Scalars['Int']>;
  date?: Maybe<Scalars['Int']>;
  trending?: Maybe<Scalars['Int']>;
  averageScore?: Maybe<Scalars['Int']>;
  popularity?: Maybe<Scalars['Int']>;
  episode?: Maybe<Scalars['Int']>;
  releasing?: Maybe<Scalars['Boolean']>;
  mediaId_not?: Maybe<Scalars['Int']>;
  mediaId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mediaId_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  date_greater?: Maybe<Scalars['Int']>;
  date_lesser?: Maybe<Scalars['Int']>;
  trending_greater?: Maybe<Scalars['Int']>;
  trending_lesser?: Maybe<Scalars['Int']>;
  trending_not?: Maybe<Scalars['Int']>;
  averageScore_greater?: Maybe<Scalars['Int']>;
  averageScore_lesser?: Maybe<Scalars['Int']>;
  averageScore_not?: Maybe<Scalars['Int']>;
  popularity_greater?: Maybe<Scalars['Int']>;
  popularity_lesser?: Maybe<Scalars['Int']>;
  popularity_not?: Maybe<Scalars['Int']>;
  episode_greater?: Maybe<Scalars['Int']>;
  episode_lesser?: Maybe<Scalars['Int']>;
  episode_not?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<MediaTrendSort>>>;
};

/** Page of data (Used for internal use only) */
export type InternalPageNotificationsArgs = {
  type?: Maybe<NotificationType>;
  resetNotificationCount?: Maybe<Scalars['Boolean']>;
  type_in?: Maybe<Array<Maybe<NotificationType>>>;
};

/** Page of data (Used for internal use only) */
export type InternalPageFollowersArgs = {
  userId: Scalars['Int'];
  sort?: Maybe<Array<Maybe<UserSort>>>;
};

/** Page of data (Used for internal use only) */
export type InternalPageFollowingArgs = {
  userId: Scalars['Int'];
  sort?: Maybe<Array<Maybe<UserSort>>>;
};

/** Page of data (Used for internal use only) */
export type InternalPageActivitiesArgs = {
  id?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  messengerId?: Maybe<Scalars['Int']>;
  mediaId?: Maybe<Scalars['Int']>;
  type?: Maybe<ActivityType>;
  isFollowing?: Maybe<Scalars['Boolean']>;
  hasReplies?: Maybe<Scalars['Boolean']>;
  hasRepliesOrTypeText?: Maybe<Scalars['Boolean']>;
  createdAt?: Maybe<Scalars['Int']>;
  id_not?: Maybe<Scalars['Int']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  id_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  userId_not?: Maybe<Scalars['Int']>;
  userId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  userId_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  messengerId_not?: Maybe<Scalars['Int']>;
  messengerId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  messengerId_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mediaId_not?: Maybe<Scalars['Int']>;
  mediaId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mediaId_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  type_not?: Maybe<ActivityType>;
  type_in?: Maybe<Array<Maybe<ActivityType>>>;
  type_not_in?: Maybe<Array<Maybe<ActivityType>>>;
  createdAt_greater?: Maybe<Scalars['Int']>;
  createdAt_lesser?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<ActivitySort>>>;
};

/** Page of data (Used for internal use only) */
export type InternalPageActivityRepliesArgs = {
  id?: Maybe<Scalars['Int']>;
  activityId?: Maybe<Scalars['Int']>;
};

/** Page of data (Used for internal use only) */
export type InternalPageThreadsArgs = {
  id?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  replyUserId?: Maybe<Scalars['Int']>;
  subscribed?: Maybe<Scalars['Boolean']>;
  categoryId?: Maybe<Scalars['Int']>;
  mediaCategoryId?: Maybe<Scalars['Int']>;
  search?: Maybe<Scalars['String']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  sort?: Maybe<Array<Maybe<ThreadSort>>>;
};

/** Page of data (Used for internal use only) */
export type InternalPageThreadCommentsArgs = {
  id?: Maybe<Scalars['Int']>;
  threadId?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<ThreadCommentSort>>>;
};

/** Page of data (Used for internal use only) */
export type InternalPageReviewsArgs = {
  id?: Maybe<Scalars['Int']>;
  mediaId?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  mediaType?: Maybe<MediaType>;
  sort?: Maybe<Array<Maybe<ReviewSort>>>;
};

/** Page of data (Used for internal use only) */
export type InternalPageRecommendationsArgs = {
  id?: Maybe<Scalars['Int']>;
  mediaId?: Maybe<Scalars['Int']>;
  mediaRecommendationId?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  rating?: Maybe<Scalars['Int']>;
  onList?: Maybe<Scalars['Boolean']>;
  rating_greater?: Maybe<Scalars['Int']>;
  rating_lesser?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<RecommendationSort>>>;
};

/** Page of data (Used for internal use only) */
export type InternalPageLikesArgs = {
  likeableId?: Maybe<Scalars['Int']>;
  type?: Maybe<LikeableType>;
};

/** Types that can be liked */
export enum LikeableType {
  Thread = 'THREAD',
  ThreadComment = 'THREAD_COMMENT',
  Activity = 'ACTIVITY',
  ActivityReply = 'ACTIVITY_REPLY',
}

/** Likeable union type */
export type LikeableUnion =
  | ListActivity
  | TextActivity
  | MessageActivity
  | ActivityReply
  | Thread
  | ThreadComment;

/** User list activity (anime & manga updates) */
export type ListActivity = {
  __typename?: 'ListActivity';
  /** The id of the activity */
  id: Scalars['Int'];
  /** The user id of the activity's creator */
  userId?: Maybe<Scalars['Int']>;
  /** The type of activity */
  type?: Maybe<ActivityType>;
  /** The number of activity replies */
  replyCount: Scalars['Int'];
  /** The list item's textual status */
  status?: Maybe<Scalars['String']>;
  /** The list progress made */
  progress?: Maybe<Scalars['String']>;
  /** If the activity is locked and can receive replies */
  isLocked?: Maybe<Scalars['Boolean']>;
  /** If the currently authenticated user is subscribed to the activity */
  isSubscribed?: Maybe<Scalars['Boolean']>;
  /** The amount of likes the activity has */
  likeCount: Scalars['Int'];
  /** If the currently authenticated user liked the activity */
  isLiked?: Maybe<Scalars['Boolean']>;
  /** The url for the activity page on the AniList website */
  siteUrl?: Maybe<Scalars['String']>;
  /** The time the activity was created at */
  createdAt: Scalars['Int'];
  /** The owner of the activity */
  user?: Maybe<User>;
  /** The associated media to the activity update */
  media?: Maybe<Media>;
  /** The written replies to the activity */
  replies?: Maybe<Array<Maybe<ActivityReply>>>;
  /** The users who liked the activity */
  likes?: Maybe<Array<Maybe<User>>>;
};

/** User's list score statistics */
export type ListScoreStats = {
  __typename?: 'ListScoreStats';
  meanScore?: Maybe<Scalars['Int']>;
  standardDeviation?: Maybe<Scalars['Int']>;
};

/** Anime or Manga */
export type Media = {
  __typename?: 'Media';
  /** The id of the media */
  id: Scalars['Int'];
  /** The mal id of the media */
  idMal?: Maybe<Scalars['Int']>;
  /** The official titles of the media in various languages */
  title?: Maybe<MediaTitle>;
  /** The type of the media; anime or manga */
  type?: Maybe<MediaType>;
  /** The format the media was released in */
  format?: Maybe<MediaFormat>;
  /** The current releasing status of the media */
  status?: Maybe<MediaStatus>;
  /** Short description of the media's story and characters */
  description?: Maybe<Scalars['String']>;
  /** The first official release date of the media */
  startDate?: Maybe<FuzzyDate>;
  /** The last official release date of the media */
  endDate?: Maybe<FuzzyDate>;
  /** The season the media was initially released in */
  season?: Maybe<MediaSeason>;
  /** The season year the media was initially released in */
  seasonYear?: Maybe<Scalars['Int']>;
  /**
   * The year & season the media was initially released in
   * @deprecated
   */
  seasonInt?: Maybe<Scalars['Int']>;
  /** The amount of episodes the anime has when complete */
  episodes?: Maybe<Scalars['Int']>;
  /** The general length of each anime episode in minutes */
  duration?: Maybe<Scalars['Int']>;
  /** The amount of chapters the manga has when complete */
  chapters?: Maybe<Scalars['Int']>;
  /** The amount of volumes the manga has when complete */
  volumes?: Maybe<Scalars['Int']>;
  /** Where the media was created. (ISO 3166-1 alpha-2) */
  countryOfOrigin?: Maybe<Scalars['CountryCode']>;
  /** If the media is officially licensed or a self-published doujin release */
  isLicensed?: Maybe<Scalars['Boolean']>;
  /** Source type the media was adapted from. */
  source?: Maybe<MediaSource>;
  /** Official Twitter hashtags for the media */
  hashtag?: Maybe<Scalars['String']>;
  /** Media trailer or advertisement */
  trailer?: Maybe<MediaTrailer>;
  /** When the media's data was last updated */
  updatedAt?: Maybe<Scalars['Int']>;
  /** The cover images of the media */
  coverImage?: Maybe<MediaCoverImage>;
  /** The banner image of the media */
  bannerImage?: Maybe<Scalars['String']>;
  /** The genres of the media */
  genres?: Maybe<Array<Maybe<Scalars['String']>>>;
  /** Alternative titles of the media */
  synonyms?: Maybe<Array<Maybe<Scalars['String']>>>;
  /** A weighted average score of all the user's scores of the media */
  averageScore?: Maybe<Scalars['Int']>;
  /** Mean score of all the user's scores of the media */
  meanScore?: Maybe<Scalars['Int']>;
  /** The number of users with the media on their list */
  popularity?: Maybe<Scalars['Int']>;
  /** Locked media may not be added to lists our favorited. This may be due to the entry pending for deletion or other reasons. */
  isLocked?: Maybe<Scalars['Boolean']>;
  /** The amount of related activity in the past hour */
  trending?: Maybe<Scalars['Int']>;
  /** The amount of user's who have favourited the media */
  favourites?: Maybe<Scalars['Int']>;
  /** List of tags that describes elements and themes of the media */
  tags?: Maybe<Array<Maybe<MediaTag>>>;
  /** Other media in the same or connecting franchise */
  relations?: Maybe<MediaConnection>;
  /** The characters in the media */
  characters?: Maybe<CharacterConnection>;
  /** The staff who produced the media */
  staff?: Maybe<StaffConnection>;
  /** The companies who produced the media */
  studios?: Maybe<StudioConnection>;
  /** If the media is marked as favourite by the current authenticated user */
  isFavourite: Scalars['Boolean'];
  /** If the media is intended only for 18+ adult audiences */
  isAdult?: Maybe<Scalars['Boolean']>;
  /** The media's next episode airing schedule */
  nextAiringEpisode?: Maybe<AiringSchedule>;
  /** The media's entire airing schedule */
  airingSchedule?: Maybe<AiringScheduleConnection>;
  /** The media's daily trend stats */
  trends?: Maybe<MediaTrendConnection>;
  /** External links to another site related to the media */
  externalLinks?: Maybe<Array<Maybe<MediaExternalLink>>>;
  /** Data and links to legal streaming episodes on external sites */
  streamingEpisodes?: Maybe<Array<Maybe<MediaStreamingEpisode>>>;
  /** The ranking of the media in a particular time span and format compared to other media */
  rankings?: Maybe<Array<Maybe<MediaRank>>>;
  /** The authenticated user's media list entry for the media */
  mediaListEntry?: Maybe<MediaList>;
  /** User reviews of the media */
  reviews?: Maybe<ReviewConnection>;
  /** User recommendations for similar media */
  recommendations?: Maybe<RecommendationConnection>;
  stats?: Maybe<MediaStats>;
  /** The url for the media page on the AniList website */
  siteUrl?: Maybe<Scalars['String']>;
  /** If the media should have forum thread automatically created for it on airing episode release */
  autoCreateForumThread?: Maybe<Scalars['Boolean']>;
  /** If the media is blocked from being recommended to/from */
  isRecommendationBlocked?: Maybe<Scalars['Boolean']>;
  /** Notes for site moderators */
  modNotes?: Maybe<Scalars['String']>;
};

/** Anime or Manga */
export type MediaStatusArgs = {
  version?: Maybe<Scalars['Int']>;
};

/** Anime or Manga */
export type MediaDescriptionArgs = {
  asHtml?: Maybe<Scalars['Boolean']>;
};

/** Anime or Manga */
export type MediaSourceArgs = {
  version?: Maybe<Scalars['Int']>;
};

/** Anime or Manga */
export type MediaCharactersArgs = {
  sort?: Maybe<Array<Maybe<CharacterSort>>>;
  role?: Maybe<CharacterRole>;
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

/** Anime or Manga */
export type MediaStaffArgs = {
  sort?: Maybe<Array<Maybe<StaffSort>>>;
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

/** Anime or Manga */
export type MediaStudiosArgs = {
  sort?: Maybe<Array<Maybe<StudioSort>>>;
  isMain?: Maybe<Scalars['Boolean']>;
};

/** Anime or Manga */
export type MediaAiringScheduleArgs = {
  notYetAired?: Maybe<Scalars['Boolean']>;
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

/** Anime or Manga */
export type MediaTrendsArgs = {
  sort?: Maybe<Array<Maybe<MediaTrendSort>>>;
  releasing?: Maybe<Scalars['Boolean']>;
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

/** Anime or Manga */
export type MediaReviewsArgs = {
  limit?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<ReviewSort>>>;
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

/** Anime or Manga */
export type MediaRecommendationsArgs = {
  sort?: Maybe<Array<Maybe<RecommendationSort>>>;
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

/** Internal - Media characters separated */
export type MediaCharacter = {
  __typename?: 'MediaCharacter';
  /** The id of the connection */
  id?: Maybe<Scalars['Int']>;
  /** The characters role in the media */
  role?: Maybe<CharacterRole>;
  roleNotes?: Maybe<Scalars['String']>;
  dubGroup?: Maybe<Scalars['String']>;
  /** Media specific character name */
  characterName?: Maybe<Scalars['String']>;
  /** The characters in the media voiced by the parent actor */
  character?: Maybe<Character>;
  /** The voice actor of the character */
  voiceActor?: Maybe<Staff>;
};

export type MediaConnection = {
  __typename?: 'MediaConnection';
  edges?: Maybe<Array<Maybe<MediaEdge>>>;
  nodes?: Maybe<Array<Maybe<Media>>>;
  /** The pagination information */
  pageInfo?: Maybe<PageInfo>;
};

export type MediaCoverImage = {
  __typename?: 'MediaCoverImage';
  /** The cover image url of the media at its largest size. If this size isn't available, large will be provided instead. */
  extraLarge?: Maybe<Scalars['String']>;
  /** The cover image url of the media at a large size */
  large?: Maybe<Scalars['String']>;
  /** The cover image url of the media at medium size */
  medium?: Maybe<Scalars['String']>;
  /** Average #hex color of cover image */
  color?: Maybe<Scalars['String']>;
};

/** Media connection edge */
export type MediaEdge = {
  __typename?: 'MediaEdge';
  node?: Maybe<Media>;
  /** The id of the connection */
  id?: Maybe<Scalars['Int']>;
  /** The type of relation to the parent model */
  relationType?: Maybe<MediaRelation>;
  /** If the studio is the main animation studio of the media (For Studio->MediaConnection field only) */
  isMainStudio: Scalars['Boolean'];
  /** The characters in the media voiced by the parent actor */
  characters?: Maybe<Array<Maybe<Character>>>;
  /** The characters role in the media */
  characterRole?: Maybe<CharacterRole>;
  /** Media specific character name */
  characterName?: Maybe<Scalars['String']>;
  /** Notes regarding the VA's role for the character */
  roleNotes?: Maybe<Scalars['String']>;
  /** Used for grouping roles where multiple dubs exist for the same language. Either dubbing company name or language variant. */
  dubGroup?: Maybe<Scalars['String']>;
  /** The role of the staff member in the production of the media */
  staffRole?: Maybe<Scalars['String']>;
  /** The voice actors of the character */
  voiceActors?: Maybe<Array<Maybe<Staff>>>;
  /** The voice actors of the character with role date */
  voiceActorRoles?: Maybe<Array<Maybe<StaffRoleType>>>;
  /** The order the media should be displayed from the users favourites */
  favouriteOrder?: Maybe<Scalars['Int']>;
};

/** Media connection edge */
export type MediaEdgeRelationTypeArgs = {
  version?: Maybe<Scalars['Int']>;
};

/** Media connection edge */
export type MediaEdgeVoiceActorsArgs = {
  language?: Maybe<StaffLanguage>;
  sort?: Maybe<Array<Maybe<StaffSort>>>;
};

/** Media connection edge */
export type MediaEdgeVoiceActorRolesArgs = {
  language?: Maybe<StaffLanguage>;
  sort?: Maybe<Array<Maybe<StaffSort>>>;
};

/** An external link to another site related to the media */
export type MediaExternalLink = {
  __typename?: 'MediaExternalLink';
  /** The id of the external link */
  id: Scalars['Int'];
  /** The url of the external link */
  url: Scalars['String'];
  /** The site location of the external link */
  site: Scalars['String'];
};

/** An external link to another site related to the media */
export type MediaExternalLinkInput = {
  /** The id of the external link */
  id: Scalars['Int'];
  /** The url of the external link */
  url: Scalars['String'];
  /** The site location of the external link */
  site: Scalars['String'];
};

/** The format the media was released in */
export enum MediaFormat {
  /** Anime broadcast on television */
  Tv = 'TV',
  /** Anime which are under 15 minutes in length and broadcast on television */
  TvShort = 'TV_SHORT',
  /** Anime movies with a theatrical release */
  Movie = 'MOVIE',
  /** Special episodes that have been included in DVD/Blu-ray releases, picture dramas, pilots, etc */
  Special = 'SPECIAL',
  /** (Original Video Animation) Anime that have been released directly on DVD/Blu-ray without originally going through a theatrical release or television broadcast */
  Ova = 'OVA',
  /** (Original Net Animation) Anime that have been originally released online or are only available through streaming services. */
  Ona = 'ONA',
  /** Short anime released as a music video */
  Music = 'MUSIC',
  /** Professionally published manga with more than one chapter */
  Manga = 'MANGA',
  /** Written books released as a series of light novels */
  Novel = 'NOVEL',
  /** Manga with just one chapter */
  OneShot = 'ONE_SHOT',
}

/** List of anime or manga */
export type MediaList = {
  __typename?: 'MediaList';
  /** The id of the list entry */
  id: Scalars['Int'];
  /** The id of the user owner of the list entry */
  userId: Scalars['Int'];
  /** The id of the media */
  mediaId: Scalars['Int'];
  /** The watching/reading status */
  status?: Maybe<MediaListStatus>;
  /** The score of the entry */
  score?: Maybe<Scalars['Float']>;
  /** The amount of episodes/chapters consumed by the user */
  progress?: Maybe<Scalars['Int']>;
  /** The amount of volumes read by the user */
  progressVolumes?: Maybe<Scalars['Int']>;
  /** The amount of times the user has rewatched/read the media */
  repeat?: Maybe<Scalars['Int']>;
  /** Priority of planning */
  priority?: Maybe<Scalars['Int']>;
  /** If the entry should only be visible to authenticated user */
  private?: Maybe<Scalars['Boolean']>;
  /** Text notes */
  notes?: Maybe<Scalars['String']>;
  /** If the entry shown be hidden from non-custom lists */
  hiddenFromStatusLists?: Maybe<Scalars['Boolean']>;
  /** Map of booleans for which custom lists the entry are in */
  customLists?: Maybe<Scalars['Json']>;
  /** Map of advanced scores with name keys */
  advancedScores?: Maybe<Scalars['Json']>;
  /** When the entry was started by the user */
  startedAt?: Maybe<FuzzyDate>;
  /** When the entry was completed by the user */
  completedAt?: Maybe<FuzzyDate>;
  /** When the entry data was last updated */
  updatedAt?: Maybe<Scalars['Int']>;
  /** When the entry data was created */
  createdAt?: Maybe<Scalars['Int']>;
  media?: Maybe<Media>;
  user?: Maybe<User>;
};

/** List of anime or manga */
export type MediaListScoreArgs = {
  format?: Maybe<ScoreFormat>;
};

/** List of anime or manga */
export type MediaListCustomListsArgs = {
  asArray?: Maybe<Scalars['Boolean']>;
};

/** List of anime or manga */
export type MediaListCollection = {
  __typename?: 'MediaListCollection';
  /** Grouped media list entries */
  lists?: Maybe<Array<Maybe<MediaListGroup>>>;
  /** The owner of the list */
  user?: Maybe<User>;
  /** If there is another chunk */
  hasNextChunk?: Maybe<Scalars['Boolean']>;
  /**
   * A map of media list entry arrays grouped by status
   * @deprecated Not GraphQL spec compliant, use lists field instead.
   */
  statusLists?: Maybe<Array<Maybe<Array<Maybe<MediaList>>>>>;
  /**
   * A map of media list entry arrays grouped by custom lists
   * @deprecated Not GraphQL spec compliant, use lists field instead.
   */
  customLists?: Maybe<Array<Maybe<Array<Maybe<MediaList>>>>>;
};

/** List of anime or manga */
export type MediaListCollectionStatusListsArgs = {
  asArray?: Maybe<Scalars['Boolean']>;
};

/** List of anime or manga */
export type MediaListCollectionCustomListsArgs = {
  asArray?: Maybe<Scalars['Boolean']>;
};

/** List group of anime or manga entries */
export type MediaListGroup = {
  __typename?: 'MediaListGroup';
  /** Media list entries */
  entries?: Maybe<Array<Maybe<MediaList>>>;
  name?: Maybe<Scalars['String']>;
  isCustomList?: Maybe<Scalars['Boolean']>;
  isSplitCompletedList?: Maybe<Scalars['Boolean']>;
  status?: Maybe<MediaListStatus>;
};

/** A user's list options */
export type MediaListOptions = {
  __typename?: 'MediaListOptions';
  /** The score format the user is using for media lists */
  scoreFormat?: Maybe<ScoreFormat>;
  /** The default order list rows should be displayed in */
  rowOrder?: Maybe<Scalars['String']>;
  /** @deprecated No longer used */
  useLegacyLists?: Maybe<Scalars['Boolean']>;
  /** The user's anime list options */
  animeList?: Maybe<MediaListTypeOptions>;
  /** The user's manga list options */
  mangaList?: Maybe<MediaListTypeOptions>;
  /**
   * The list theme options for both lists
   * @deprecated No longer used
   */
  sharedTheme?: Maybe<Scalars['Json']>;
  /**
   * If the shared theme should be used instead of the individual list themes
   * @deprecated No longer used
   */
  sharedThemeEnabled?: Maybe<Scalars['Boolean']>;
};

/** A user's list options for anime or manga lists */
export type MediaListOptionsInput = {
  /** The order each list should be displayed in */
  sectionOrder?: Maybe<Array<Maybe<Scalars['String']>>>;
  /** If the completed sections of the list should be separated by format */
  splitCompletedSectionByFormat?: Maybe<Scalars['Boolean']>;
  /** The names of the user's custom lists */
  customLists?: Maybe<Array<Maybe<Scalars['String']>>>;
  /** The names of the user's advanced scoring sections */
  advancedScoring?: Maybe<Array<Maybe<Scalars['String']>>>;
  /** If advanced scoring is enabled */
  advancedScoringEnabled?: Maybe<Scalars['Boolean']>;
  /** list theme */
  theme?: Maybe<Scalars['String']>;
};

/** Media list sort enums */
export enum MediaListSort {
  MediaId = 'MEDIA_ID',
  MediaIdDesc = 'MEDIA_ID_DESC',
  Score = 'SCORE',
  ScoreDesc = 'SCORE_DESC',
  Status = 'STATUS',
  StatusDesc = 'STATUS_DESC',
  Progress = 'PROGRESS',
  ProgressDesc = 'PROGRESS_DESC',
  ProgressVolumes = 'PROGRESS_VOLUMES',
  ProgressVolumesDesc = 'PROGRESS_VOLUMES_DESC',
  Repeat = 'REPEAT',
  RepeatDesc = 'REPEAT_DESC',
  Priority = 'PRIORITY',
  PriorityDesc = 'PRIORITY_DESC',
  StartedOn = 'STARTED_ON',
  StartedOnDesc = 'STARTED_ON_DESC',
  FinishedOn = 'FINISHED_ON',
  FinishedOnDesc = 'FINISHED_ON_DESC',
  AddedTime = 'ADDED_TIME',
  AddedTimeDesc = 'ADDED_TIME_DESC',
  UpdatedTime = 'UPDATED_TIME',
  UpdatedTimeDesc = 'UPDATED_TIME_DESC',
  MediaTitleRomaji = 'MEDIA_TITLE_ROMAJI',
  MediaTitleRomajiDesc = 'MEDIA_TITLE_ROMAJI_DESC',
  MediaTitleEnglish = 'MEDIA_TITLE_ENGLISH',
  MediaTitleEnglishDesc = 'MEDIA_TITLE_ENGLISH_DESC',
  MediaTitleNative = 'MEDIA_TITLE_NATIVE',
  MediaTitleNativeDesc = 'MEDIA_TITLE_NATIVE_DESC',
  MediaPopularity = 'MEDIA_POPULARITY',
  MediaPopularityDesc = 'MEDIA_POPULARITY_DESC',
}

/** Media list watching/reading status enum. */
export enum MediaListStatus {
  /** Currently watching/reading */
  Current = 'CURRENT',
  /** Planning to watch/read */
  Planning = 'PLANNING',
  /** Finished watching/reading */
  Completed = 'COMPLETED',
  /** Stopped watching/reading before completing */
  Dropped = 'DROPPED',
  /** Paused watching/reading */
  Paused = 'PAUSED',
  /** Re-watching/reading */
  Repeating = 'REPEATING',
}

/** A user's list options for anime or manga lists */
export type MediaListTypeOptions = {
  __typename?: 'MediaListTypeOptions';
  /** The order each list should be displayed in */
  sectionOrder?: Maybe<Array<Maybe<Scalars['String']>>>;
  /** If the completed sections of the list should be separated by format */
  splitCompletedSectionByFormat?: Maybe<Scalars['Boolean']>;
  /**
   * The list theme options
   * @deprecated This field has not yet been fully implemented and may change without warning
   */
  theme?: Maybe<Scalars['Json']>;
  /** The names of the user's custom lists */
  customLists?: Maybe<Array<Maybe<Scalars['String']>>>;
  /** The names of the user's advanced scoring sections */
  advancedScoring?: Maybe<Array<Maybe<Scalars['String']>>>;
  /** If advanced scoring is enabled */
  advancedScoringEnabled?: Maybe<Scalars['Boolean']>;
};

/** The ranking of a media in a particular time span and format compared to other media */
export type MediaRank = {
  __typename?: 'MediaRank';
  /** The id of the rank */
  id: Scalars['Int'];
  /** The numerical rank of the media */
  rank: Scalars['Int'];
  /** The type of ranking */
  type: MediaRankType;
  /** The format the media is ranked within */
  format: MediaFormat;
  /** The year the media is ranked within */
  year?: Maybe<Scalars['Int']>;
  /** The season the media is ranked within */
  season?: Maybe<MediaSeason>;
  /** If the ranking is based on all time instead of a season/year */
  allTime?: Maybe<Scalars['Boolean']>;
  /** String that gives context to the ranking type and time span */
  context: Scalars['String'];
};

/** The type of ranking */
export enum MediaRankType {
  /** Ranking is based on the media's ratings/score */
  Rated = 'RATED',
  /** Ranking is based on the media's popularity */
  Popular = 'POPULAR',
}

/** Type of relation media has to its parent. */
export enum MediaRelation {
  /** An adaption of this media into a different format */
  Adaptation = 'ADAPTATION',
  /** Released before the relation */
  Prequel = 'PREQUEL',
  /** Released after the relation */
  Sequel = 'SEQUEL',
  /** The media a side story is from */
  Parent = 'PARENT',
  /** A side story of the parent media */
  SideStory = 'SIDE_STORY',
  /** Shares at least 1 character */
  Character = 'CHARACTER',
  /** A shortened and summarized version */
  Summary = 'SUMMARY',
  /** An alternative version of the same media */
  Alternative = 'ALTERNATIVE',
  /** An alternative version of the media with a different primary focus */
  SpinOff = 'SPIN_OFF',
  /** Other */
  Other = 'OTHER',
  /** Version 2 only. The source material the media was adapted from */
  Source = 'SOURCE',
  /** Version 2 only. */
  Compilation = 'COMPILATION',
  /** Version 2 only. */
  Contains = 'CONTAINS',
}

export enum MediaSeason {
  /** Months December to February */
  Winter = 'WINTER',
  /** Months March to May */
  Spring = 'SPRING',
  /** Months June to August */
  Summer = 'SUMMER',
  /** Months September to November */
  Fall = 'FALL',
}

/** Media sort enums */
export enum MediaSort {
  Id = 'ID',
  IdDesc = 'ID_DESC',
  TitleRomaji = 'TITLE_ROMAJI',
  TitleRomajiDesc = 'TITLE_ROMAJI_DESC',
  TitleEnglish = 'TITLE_ENGLISH',
  TitleEnglishDesc = 'TITLE_ENGLISH_DESC',
  TitleNative = 'TITLE_NATIVE',
  TitleNativeDesc = 'TITLE_NATIVE_DESC',
  Type = 'TYPE',
  TypeDesc = 'TYPE_DESC',
  Format = 'FORMAT',
  FormatDesc = 'FORMAT_DESC',
  StartDate = 'START_DATE',
  StartDateDesc = 'START_DATE_DESC',
  EndDate = 'END_DATE',
  EndDateDesc = 'END_DATE_DESC',
  Score = 'SCORE',
  ScoreDesc = 'SCORE_DESC',
  Popularity = 'POPULARITY',
  PopularityDesc = 'POPULARITY_DESC',
  Trending = 'TRENDING',
  TrendingDesc = 'TRENDING_DESC',
  Episodes = 'EPISODES',
  EpisodesDesc = 'EPISODES_DESC',
  Duration = 'DURATION',
  DurationDesc = 'DURATION_DESC',
  Status = 'STATUS',
  StatusDesc = 'STATUS_DESC',
  Chapters = 'CHAPTERS',
  ChaptersDesc = 'CHAPTERS_DESC',
  Volumes = 'VOLUMES',
  VolumesDesc = 'VOLUMES_DESC',
  UpdatedAt = 'UPDATED_AT',
  UpdatedAtDesc = 'UPDATED_AT_DESC',
  SearchMatch = 'SEARCH_MATCH',
  Favourites = 'FAVOURITES',
  FavouritesDesc = 'FAVOURITES_DESC',
}

/** Source type the media was adapted from */
export enum MediaSource {
  /** An original production not based of another work */
  Original = 'ORIGINAL',
  /** Asian comic book */
  Manga = 'MANGA',
  /** Written work published in volumes */
  LightNovel = 'LIGHT_NOVEL',
  /** Video game driven primary by text and narrative */
  VisualNovel = 'VISUAL_NOVEL',
  /** Video game */
  VideoGame = 'VIDEO_GAME',
  /** Other */
  Other = 'OTHER',
  /** Version 2 only. Written works not published in volumes */
  Novel = 'NOVEL',
  /** Version 2 only. Self-published works */
  Doujinshi = 'DOUJINSHI',
  /** Version 2 only. Japanese Anime */
  Anime = 'ANIME',
}

/** A media's statistics */
export type MediaStats = {
  __typename?: 'MediaStats';
  scoreDistribution?: Maybe<Array<Maybe<ScoreDistribution>>>;
  statusDistribution?: Maybe<Array<Maybe<StatusDistribution>>>;
  /** @deprecated Replaced by MediaTrends */
  airingProgression?: Maybe<Array<Maybe<AiringProgression>>>;
};

/** The current releasing status of the media */
export enum MediaStatus {
  /** Has completed and is no longer being released */
  Finished = 'FINISHED',
  /** Currently releasing */
  Releasing = 'RELEASING',
  /** To be released at a later date */
  NotYetReleased = 'NOT_YET_RELEASED',
  /** Ended before the work could be finished */
  Cancelled = 'CANCELLED',
  /** Version 2 only. Is currently paused from releasing and will resume at a later date */
  Hiatus = 'HIATUS',
}

/** Data and links to legal streaming episodes on external sites */
export type MediaStreamingEpisode = {
  __typename?: 'MediaStreamingEpisode';
  /** Title of the episode */
  title?: Maybe<Scalars['String']>;
  /** Url of episode image thumbnail */
  thumbnail?: Maybe<Scalars['String']>;
  /** The url of the episode */
  url?: Maybe<Scalars['String']>;
  /** The site location of the streaming episodes */
  site?: Maybe<Scalars['String']>;
};

/** Media submission */
export type MediaSubmission = {
  __typename?: 'MediaSubmission';
  /** The id of the submission */
  id: Scalars['Int'];
  /** User submitter of the submission */
  submitter?: Maybe<User>;
  /** Status of the submission */
  status?: Maybe<SubmissionStatus>;
  submitterStats?: Maybe<Scalars['Json']>;
  notes?: Maybe<Scalars['String']>;
  source?: Maybe<Scalars['String']>;
  changes?: Maybe<Array<Maybe<Scalars['String']>>>;
  media?: Maybe<Media>;
  submission?: Maybe<Media>;
  characters?: Maybe<Array<Maybe<MediaSubmissionComparison>>>;
  staff?: Maybe<Array<Maybe<MediaSubmissionComparison>>>;
  studios?: Maybe<Array<Maybe<MediaSubmissionComparison>>>;
  relations?: Maybe<Array<Maybe<MediaEdge>>>;
  externalLinks?: Maybe<Array<Maybe<MediaExternalLink>>>;
  createdAt?: Maybe<Scalars['Int']>;
};

/** Media submission with comparison to current data */
export type MediaSubmissionComparison = {
  __typename?: 'MediaSubmissionComparison';
  submission?: Maybe<MediaSubmissionEdge>;
  character?: Maybe<MediaCharacter>;
  staff?: Maybe<StaffEdge>;
  studio?: Maybe<StudioEdge>;
};

export type MediaSubmissionEdge = {
  __typename?: 'MediaSubmissionEdge';
  /** The id of the direct submission */
  id?: Maybe<Scalars['Int']>;
  characterRole?: Maybe<CharacterRole>;
  staffRole?: Maybe<Scalars['String']>;
  roleNotes?: Maybe<Scalars['String']>;
  dubGroup?: Maybe<Scalars['String']>;
  characterName?: Maybe<Scalars['String']>;
  isMain?: Maybe<Scalars['Boolean']>;
  character?: Maybe<Character>;
  characterSubmission?: Maybe<Character>;
  voiceActor?: Maybe<Staff>;
  voiceActorSubmission?: Maybe<Staff>;
  staff?: Maybe<Staff>;
  staffSubmission?: Maybe<Staff>;
  studio?: Maybe<Studio>;
  media?: Maybe<Media>;
};

/** A tag that describes a theme or element of the media */
export type MediaTag = {
  __typename?: 'MediaTag';
  /** The id of the tag */
  id: Scalars['Int'];
  /** The name of the tag */
  name: Scalars['String'];
  /** A general description of the tag */
  description?: Maybe<Scalars['String']>;
  /** The categories of tags this tag belongs to */
  category?: Maybe<Scalars['String']>;
  /** The relevance ranking of the tag out of the 100 for this media */
  rank?: Maybe<Scalars['Int']>;
  /** If the tag could be a spoiler for any media */
  isGeneralSpoiler?: Maybe<Scalars['Boolean']>;
  /** If the tag is a spoiler for this media */
  isMediaSpoiler?: Maybe<Scalars['Boolean']>;
  /** If the tag is only for adult 18+ media */
  isAdult?: Maybe<Scalars['Boolean']>;
};

/** The official titles of the media in various languages */
export type MediaTitle = {
  __typename?: 'MediaTitle';
  /** The romanization of the native language title */
  romaji?: Maybe<Scalars['String']>;
  /** The official english title */
  english?: Maybe<Scalars['String']>;
  /** Official title in it's native language */
  native?: Maybe<Scalars['String']>;
  /** The currently authenticated users preferred title language. Default romaji for non-authenticated */
  userPreferred?: Maybe<Scalars['String']>;
};

/** The official titles of the media in various languages */
export type MediaTitleRomajiArgs = {
  stylised?: Maybe<Scalars['Boolean']>;
};

/** The official titles of the media in various languages */
export type MediaTitleEnglishArgs = {
  stylised?: Maybe<Scalars['Boolean']>;
};

/** The official titles of the media in various languages */
export type MediaTitleNativeArgs = {
  stylised?: Maybe<Scalars['Boolean']>;
};

/** The official titles of the media in various languages */
export type MediaTitleInput = {
  /** The romanization of the native language title */
  romaji?: Maybe<Scalars['String']>;
  /** The official english title */
  english?: Maybe<Scalars['String']>;
  /** Official title in it's native language */
  native?: Maybe<Scalars['String']>;
};

/** Media trailer or advertisement */
export type MediaTrailer = {
  __typename?: 'MediaTrailer';
  /** The trailer video id */
  id?: Maybe<Scalars['String']>;
  /** The site the video is hosted by (Currently either youtube or dailymotion) */
  site?: Maybe<Scalars['String']>;
  /** The url for the thumbnail image of the video */
  thumbnail?: Maybe<Scalars['String']>;
};

/** Daily media statistics */
export type MediaTrend = {
  __typename?: 'MediaTrend';
  /** The id of the tag */
  mediaId: Scalars['Int'];
  /** The day the data was recorded (timestamp) */
  date: Scalars['Int'];
  /** The amount of media activity on the day */
  trending: Scalars['Int'];
  /** A weighted average score of all the user's scores of the media */
  averageScore?: Maybe<Scalars['Int']>;
  /** The number of users with the media on their list */
  popularity?: Maybe<Scalars['Int']>;
  /** The number of users with watching/reading the media */
  inProgress?: Maybe<Scalars['Int']>;
  /** If the media was being released at this time */
  releasing: Scalars['Boolean'];
  /** The episode number of the anime released on this day */
  episode?: Maybe<Scalars['Int']>;
  /** The related media */
  media?: Maybe<Media>;
};

export type MediaTrendConnection = {
  __typename?: 'MediaTrendConnection';
  edges?: Maybe<Array<Maybe<MediaTrendEdge>>>;
  nodes?: Maybe<Array<Maybe<MediaTrend>>>;
  /** The pagination information */
  pageInfo?: Maybe<PageInfo>;
};

/** Media trend connection edge */
export type MediaTrendEdge = {
  __typename?: 'MediaTrendEdge';
  node?: Maybe<MediaTrend>;
};

/** Media trend sort enums */
export enum MediaTrendSort {
  Id = 'ID',
  IdDesc = 'ID_DESC',
  MediaId = 'MEDIA_ID',
  MediaIdDesc = 'MEDIA_ID_DESC',
  Date = 'DATE',
  DateDesc = 'DATE_DESC',
  Score = 'SCORE',
  ScoreDesc = 'SCORE_DESC',
  Popularity = 'POPULARITY',
  PopularityDesc = 'POPULARITY_DESC',
  Trending = 'TRENDING',
  TrendingDesc = 'TRENDING_DESC',
  Episode = 'EPISODE',
  EpisodeDesc = 'EPISODE_DESC',
}

/** Media type enum, anime or manga. */
export enum MediaType {
  /** Japanese Anime */
  Anime = 'ANIME',
  /** Asian comic */
  Manga = 'MANGA',
}

/** User message activity */
export type MessageActivity = {
  __typename?: 'MessageActivity';
  /** The id of the activity */
  id: Scalars['Int'];
  /** The user id of the activity's recipient */
  recipientId?: Maybe<Scalars['Int']>;
  /** The user id of the activity's sender */
  messengerId?: Maybe<Scalars['Int']>;
  /** The type of the activity */
  type?: Maybe<ActivityType>;
  /** The number of activity replies */
  replyCount: Scalars['Int'];
  /** The message text (Markdown) */
  message?: Maybe<Scalars['String']>;
  /** If the activity is locked and can receive replies */
  isLocked?: Maybe<Scalars['Boolean']>;
  /** If the currently authenticated user is subscribed to the activity */
  isSubscribed?: Maybe<Scalars['Boolean']>;
  /** The amount of likes the activity has */
  likeCount: Scalars['Int'];
  /** If the currently authenticated user liked the activity */
  isLiked?: Maybe<Scalars['Boolean']>;
  /** If the message is private and only viewable to the sender and recipients */
  isPrivate?: Maybe<Scalars['Boolean']>;
  /** The url for the activity page on the AniList website */
  siteUrl?: Maybe<Scalars['String']>;
  /** The time the activity was created at */
  createdAt: Scalars['Int'];
  /** The user who the activity message was sent to */
  recipient?: Maybe<User>;
  /** The user who sent the activity message */
  messenger?: Maybe<User>;
  /** The written replies to the activity */
  replies?: Maybe<Array<Maybe<ActivityReply>>>;
  /** The users who liked the activity */
  likes?: Maybe<Array<Maybe<User>>>;
};

/** User message activity */
export type MessageActivityMessageArgs = {
  asHtml?: Maybe<Scalars['Boolean']>;
};

export type ModAction = {
  __typename?: 'ModAction';
  /** The id of the action */
  id: Scalars['Int'];
  user?: Maybe<User>;
  mod?: Maybe<User>;
  type?: Maybe<ModActionType>;
  objectId?: Maybe<Scalars['Int']>;
  objectType?: Maybe<Scalars['String']>;
  data?: Maybe<Scalars['String']>;
  createdAt: Scalars['Int'];
};

export enum ModActionType {
  Note = 'NOTE',
  Ban = 'BAN',
  Delete = 'DELETE',
  Edit = 'EDIT',
  Expire = 'EXPIRE',
  Report = 'REPORT',
  Reset = 'RESET',
  Anon = 'ANON',
}

/** Mod role enums */
export enum ModRole {
  /** An AniList administrator */
  Admin = 'ADMIN',
  /** A head developer of AniList */
  LeadDeveloper = 'LEAD_DEVELOPER',
  /** An AniList developer */
  Developer = 'DEVELOPER',
  /** A lead community moderator */
  LeadCommunity = 'LEAD_COMMUNITY',
  /** A community moderator */
  Community = 'COMMUNITY',
  /** A discord community moderator */
  DiscordCommunity = 'DISCORD_COMMUNITY',
  /** A lead anime data moderator */
  LeadAnimeData = 'LEAD_ANIME_DATA',
  /** An anime data moderator */
  AnimeData = 'ANIME_DATA',
  /** A lead manga data moderator */
  LeadMangaData = 'LEAD_MANGA_DATA',
  /** A manga data moderator */
  MangaData = 'MANGA_DATA',
  /** A lead social media moderator */
  LeadSocialMedia = 'LEAD_SOCIAL_MEDIA',
  /** A social media moderator */
  SocialMedia = 'SOCIAL_MEDIA',
  /** A retired moderator */
  Retired = 'RETIRED',
}

export type Mutation = {
  __typename?: 'Mutation';
  UpdateUser?: Maybe<User>;
  /** Create or update a media list entry */
  SaveMediaListEntry?: Maybe<MediaList>;
  /** Update multiple media list entries to the same values */
  UpdateMediaListEntries?: Maybe<Array<Maybe<MediaList>>>;
  /** Delete a media list entry */
  DeleteMediaListEntry?: Maybe<Deleted>;
  /** Delete a custom list and remove the list entries from it */
  DeleteCustomList?: Maybe<Deleted>;
  /** Create or update text activity for the currently authenticated user */
  SaveTextActivity?: Maybe<TextActivity>;
  /** Create or update message activity for the currently authenticated user */
  SaveMessageActivity?: Maybe<MessageActivity>;
  /** Update list activity (Mod Only) */
  SaveListActivity?: Maybe<ListActivity>;
  /** Delete an activity item of the authenticated users */
  DeleteActivity?: Maybe<Deleted>;
  /** Toggle the subscription of an activity item */
  ToggleActivitySubscription?: Maybe<ActivityUnion>;
  /** Create or update an activity reply */
  SaveActivityReply?: Maybe<ActivityReply>;
  /** Delete an activity reply of the authenticated users */
  DeleteActivityReply?: Maybe<Deleted>;
  /**
   * Add or remove a like from a likeable type.
   *                           Returns all the users who liked the same model
   */
  ToggleLike?: Maybe<Array<Maybe<User>>>;
  /** Add or remove a like from a likeable type. */
  ToggleLikeV2?: Maybe<LikeableUnion>;
  /** Toggle the un/following of a user */
  ToggleFollow?: Maybe<User>;
  /** Favourite or unfavourite an anime, manga, character, staff member, or studio */
  ToggleFavourite?: Maybe<Favourites>;
  /** Update the order favourites are displayed in */
  UpdateFavouriteOrder?: Maybe<Favourites>;
  /** Create or update a review */
  SaveReview?: Maybe<Review>;
  /** Delete a review */
  DeleteReview?: Maybe<Deleted>;
  /** Rate a review */
  RateReview?: Maybe<Review>;
  /** Recommendation a media */
  SaveRecommendation?: Maybe<Recommendation>;
  /** Create or update a forum thread */
  SaveThread?: Maybe<Thread>;
  /** Delete a thread */
  DeleteThread?: Maybe<Deleted>;
  /** Toggle the subscription of a forum thread */
  ToggleThreadSubscription?: Maybe<Thread>;
  /** Create or update a thread comment */
  SaveThreadComment?: Maybe<ThreadComment>;
  /** Delete a thread comment */
  DeleteThreadComment?: Maybe<Deleted>;
  UpdateAniChartSettings?: Maybe<Scalars['Json']>;
  UpdateAniChartHighlights?: Maybe<Scalars['Json']>;
};

export type MutationUpdateUserArgs = {
  about?: Maybe<Scalars['String']>;
  titleLanguage?: Maybe<UserTitleLanguage>;
  displayAdultContent?: Maybe<Scalars['Boolean']>;
  airingNotifications?: Maybe<Scalars['Boolean']>;
  scoreFormat?: Maybe<ScoreFormat>;
  rowOrder?: Maybe<Scalars['String']>;
  profileColor?: Maybe<Scalars['String']>;
  donatorBadge?: Maybe<Scalars['String']>;
  notificationOptions?: Maybe<Array<Maybe<NotificationOptionInput>>>;
  timezone?: Maybe<Scalars['String']>;
  activityMergeTime?: Maybe<Scalars['Int']>;
  animeListOptions?: Maybe<MediaListOptionsInput>;
  mangaListOptions?: Maybe<MediaListOptionsInput>;
  staffNameLanguage?: Maybe<UserStaffNameLanguage>;
};

export type MutationSaveMediaListEntryArgs = {
  id?: Maybe<Scalars['Int']>;
  mediaId?: Maybe<Scalars['Int']>;
  status?: Maybe<MediaListStatus>;
  score?: Maybe<Scalars['Float']>;
  scoreRaw?: Maybe<Scalars['Int']>;
  progress?: Maybe<Scalars['Int']>;
  progressVolumes?: Maybe<Scalars['Int']>;
  repeat?: Maybe<Scalars['Int']>;
  priority?: Maybe<Scalars['Int']>;
  private?: Maybe<Scalars['Boolean']>;
  notes?: Maybe<Scalars['String']>;
  hiddenFromStatusLists?: Maybe<Scalars['Boolean']>;
  customLists?: Maybe<Array<Maybe<Scalars['String']>>>;
  advancedScores?: Maybe<Array<Maybe<Scalars['Float']>>>;
  startedAt?: Maybe<FuzzyDateInput>;
  completedAt?: Maybe<FuzzyDateInput>;
};

export type MutationUpdateMediaListEntriesArgs = {
  status?: Maybe<MediaListStatus>;
  score?: Maybe<Scalars['Float']>;
  scoreRaw?: Maybe<Scalars['Int']>;
  progress?: Maybe<Scalars['Int']>;
  progressVolumes?: Maybe<Scalars['Int']>;
  repeat?: Maybe<Scalars['Int']>;
  priority?: Maybe<Scalars['Int']>;
  private?: Maybe<Scalars['Boolean']>;
  notes?: Maybe<Scalars['String']>;
  hiddenFromStatusLists?: Maybe<Scalars['Boolean']>;
  advancedScores?: Maybe<Array<Maybe<Scalars['Float']>>>;
  startedAt?: Maybe<FuzzyDateInput>;
  completedAt?: Maybe<FuzzyDateInput>;
  ids?: Maybe<Array<Maybe<Scalars['Int']>>>;
};

export type MutationDeleteMediaListEntryArgs = {
  id?: Maybe<Scalars['Int']>;
};

export type MutationDeleteCustomListArgs = {
  customList?: Maybe<Scalars['String']>;
  type?: Maybe<MediaType>;
};

export type MutationSaveTextActivityArgs = {
  id?: Maybe<Scalars['Int']>;
  text?: Maybe<Scalars['String']>;
  locked?: Maybe<Scalars['Boolean']>;
};

export type MutationSaveMessageActivityArgs = {
  id?: Maybe<Scalars['Int']>;
  message?: Maybe<Scalars['String']>;
  recipientId?: Maybe<Scalars['Int']>;
  private?: Maybe<Scalars['Boolean']>;
  locked?: Maybe<Scalars['Boolean']>;
  asMod?: Maybe<Scalars['Boolean']>;
};

export type MutationSaveListActivityArgs = {
  id?: Maybe<Scalars['Int']>;
  locked?: Maybe<Scalars['Boolean']>;
};

export type MutationDeleteActivityArgs = {
  id?: Maybe<Scalars['Int']>;
};

export type MutationToggleActivitySubscriptionArgs = {
  activityId?: Maybe<Scalars['Int']>;
  subscribe?: Maybe<Scalars['Boolean']>;
};

export type MutationSaveActivityReplyArgs = {
  id?: Maybe<Scalars['Int']>;
  activityId?: Maybe<Scalars['Int']>;
  text?: Maybe<Scalars['String']>;
  asMod?: Maybe<Scalars['Boolean']>;
};

export type MutationDeleteActivityReplyArgs = {
  id?: Maybe<Scalars['Int']>;
};

export type MutationToggleLikeArgs = {
  id?: Maybe<Scalars['Int']>;
  type?: Maybe<LikeableType>;
};

export type MutationToggleLikeV2Args = {
  id?: Maybe<Scalars['Int']>;
  type?: Maybe<LikeableType>;
};

export type MutationToggleFollowArgs = {
  userId?: Maybe<Scalars['Int']>;
};

export type MutationToggleFavouriteArgs = {
  animeId?: Maybe<Scalars['Int']>;
  mangaId?: Maybe<Scalars['Int']>;
  characterId?: Maybe<Scalars['Int']>;
  staffId?: Maybe<Scalars['Int']>;
  studioId?: Maybe<Scalars['Int']>;
};

export type MutationUpdateFavouriteOrderArgs = {
  animeIds?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mangaIds?: Maybe<Array<Maybe<Scalars['Int']>>>;
  characterIds?: Maybe<Array<Maybe<Scalars['Int']>>>;
  staffIds?: Maybe<Array<Maybe<Scalars['Int']>>>;
  studioIds?: Maybe<Array<Maybe<Scalars['Int']>>>;
  animeOrder?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mangaOrder?: Maybe<Array<Maybe<Scalars['Int']>>>;
  characterOrder?: Maybe<Array<Maybe<Scalars['Int']>>>;
  staffOrder?: Maybe<Array<Maybe<Scalars['Int']>>>;
  studioOrder?: Maybe<Array<Maybe<Scalars['Int']>>>;
};

export type MutationSaveReviewArgs = {
  id?: Maybe<Scalars['Int']>;
  mediaId?: Maybe<Scalars['Int']>;
  body?: Maybe<Scalars['String']>;
  summary?: Maybe<Scalars['String']>;
  score?: Maybe<Scalars['Int']>;
  private?: Maybe<Scalars['Boolean']>;
};

export type MutationDeleteReviewArgs = {
  id?: Maybe<Scalars['Int']>;
};

export type MutationRateReviewArgs = {
  reviewId?: Maybe<Scalars['Int']>;
  rating?: Maybe<ReviewRating>;
};

export type MutationSaveRecommendationArgs = {
  mediaId?: Maybe<Scalars['Int']>;
  mediaRecommendationId?: Maybe<Scalars['Int']>;
  rating?: Maybe<RecommendationRating>;
};

export type MutationSaveThreadArgs = {
  id?: Maybe<Scalars['Int']>;
  title?: Maybe<Scalars['String']>;
  body?: Maybe<Scalars['String']>;
  categories?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mediaCategories?: Maybe<Array<Maybe<Scalars['Int']>>>;
  sticky?: Maybe<Scalars['Boolean']>;
  locked?: Maybe<Scalars['Boolean']>;
};

export type MutationDeleteThreadArgs = {
  id?: Maybe<Scalars['Int']>;
};

export type MutationToggleThreadSubscriptionArgs = {
  threadId?: Maybe<Scalars['Int']>;
  subscribe?: Maybe<Scalars['Boolean']>;
};

export type MutationSaveThreadCommentArgs = {
  id?: Maybe<Scalars['Int']>;
  threadId?: Maybe<Scalars['Int']>;
  parentCommentId?: Maybe<Scalars['Int']>;
  comment?: Maybe<Scalars['String']>;
};

export type MutationDeleteThreadCommentArgs = {
  id?: Maybe<Scalars['Int']>;
};

export type MutationUpdateAniChartSettingsArgs = {
  titleLanguage?: Maybe<Scalars['String']>;
  outgoingLinkProvider?: Maybe<Scalars['String']>;
  theme?: Maybe<Scalars['String']>;
  sort?: Maybe<Scalars['String']>;
};

export type MutationUpdateAniChartHighlightsArgs = {
  highlights?: Maybe<Array<Maybe<AniChartHighlightInput>>>;
};

/** Notification option */
export type NotificationOption = {
  __typename?: 'NotificationOption';
  /** The type of notification */
  type?: Maybe<NotificationType>;
  /** Whether this type of notification is enabled */
  enabled?: Maybe<Scalars['Boolean']>;
};

/** Notification option input */
export type NotificationOptionInput = {
  /** The type of notification */
  type?: Maybe<NotificationType>;
  /** Whether this type of notification is enabled */
  enabled?: Maybe<Scalars['Boolean']>;
};

/** Notification type enum */
export enum NotificationType {
  /** A user has sent you message */
  ActivityMessage = 'ACTIVITY_MESSAGE',
  /** A user has replied to your activity */
  ActivityReply = 'ACTIVITY_REPLY',
  /** A user has followed you */
  Following = 'FOLLOWING',
  /** A user has mentioned you in their activity */
  ActivityMention = 'ACTIVITY_MENTION',
  /** A user has mentioned you in a forum comment */
  ThreadCommentMention = 'THREAD_COMMENT_MENTION',
  /** A user has commented in one of your subscribed forum threads */
  ThreadSubscribed = 'THREAD_SUBSCRIBED',
  /** A user has replied to your forum comment */
  ThreadCommentReply = 'THREAD_COMMENT_REPLY',
  /** An anime you are currently watching has aired */
  Airing = 'AIRING',
  /** A user has liked your activity */
  ActivityLike = 'ACTIVITY_LIKE',
  /** A user has liked your activity reply */
  ActivityReplyLike = 'ACTIVITY_REPLY_LIKE',
  /** A user has liked your forum thread */
  ThreadLike = 'THREAD_LIKE',
  /** A user has liked your forum comment */
  ThreadCommentLike = 'THREAD_COMMENT_LIKE',
  /** A user has replied to activity you have also replied to */
  ActivityReplySubscribed = 'ACTIVITY_REPLY_SUBSCRIBED',
  /** A new anime or manga has been added to the site where its related media is on the user's list */
  RelatedMediaAddition = 'RELATED_MEDIA_ADDITION',
}

/** Notification union type */
export type NotificationUnion =
  | AiringNotification
  | FollowingNotification
  | ActivityMessageNotification
  | ActivityMentionNotification
  | ActivityReplyNotification
  | ActivityReplySubscribedNotification
  | ActivityLikeNotification
  | ActivityReplyLikeNotification
  | ThreadCommentMentionNotification
  | ThreadCommentReplyNotification
  | ThreadCommentSubscribedNotification
  | ThreadCommentLikeNotification
  | ThreadLikeNotification
  | RelatedMediaAdditionNotification;

/** Page of data */
export type Page = {
  __typename?: 'Page';
  /** The pagination information */
  pageInfo?: Maybe<PageInfo>;
  users?: Maybe<Array<Maybe<User>>>;
  media?: Maybe<Array<Maybe<Media>>>;
  characters?: Maybe<Array<Maybe<Character>>>;
  staff?: Maybe<Array<Maybe<Staff>>>;
  studios?: Maybe<Array<Maybe<Studio>>>;
  mediaList?: Maybe<Array<Maybe<MediaList>>>;
  airingSchedules?: Maybe<Array<Maybe<AiringSchedule>>>;
  mediaTrends?: Maybe<Array<Maybe<MediaTrend>>>;
  notifications?: Maybe<Array<Maybe<NotificationUnion>>>;
  followers?: Maybe<Array<Maybe<User>>>;
  following?: Maybe<Array<Maybe<User>>>;
  activities?: Maybe<Array<Maybe<ActivityUnion>>>;
  activityReplies?: Maybe<Array<Maybe<ActivityReply>>>;
  threads?: Maybe<Array<Maybe<Thread>>>;
  threadComments?: Maybe<Array<Maybe<ThreadComment>>>;
  reviews?: Maybe<Array<Maybe<Review>>>;
  recommendations?: Maybe<Array<Maybe<Recommendation>>>;
  likes?: Maybe<Array<Maybe<User>>>;
};

/** Page of data */
export type PageUsersArgs = {
  id?: Maybe<Scalars['Int']>;
  name?: Maybe<Scalars['String']>;
  isModerator?: Maybe<Scalars['Boolean']>;
  search?: Maybe<Scalars['String']>;
  sort?: Maybe<Array<Maybe<UserSort>>>;
};

/** Page of data */
export type PageMediaArgs = {
  id?: Maybe<Scalars['Int']>;
  idMal?: Maybe<Scalars['Int']>;
  startDate?: Maybe<Scalars['FuzzyDateInt']>;
  endDate?: Maybe<Scalars['FuzzyDateInt']>;
  season?: Maybe<MediaSeason>;
  seasonYear?: Maybe<Scalars['Int']>;
  type?: Maybe<MediaType>;
  format?: Maybe<MediaFormat>;
  status?: Maybe<MediaStatus>;
  episodes?: Maybe<Scalars['Int']>;
  duration?: Maybe<Scalars['Int']>;
  chapters?: Maybe<Scalars['Int']>;
  volumes?: Maybe<Scalars['Int']>;
  isAdult?: Maybe<Scalars['Boolean']>;
  genre?: Maybe<Scalars['String']>;
  tag?: Maybe<Scalars['String']>;
  minimumTagRank?: Maybe<Scalars['Int']>;
  tagCategory?: Maybe<Scalars['String']>;
  onList?: Maybe<Scalars['Boolean']>;
  licensedBy?: Maybe<Scalars['String']>;
  averageScore?: Maybe<Scalars['Int']>;
  popularity?: Maybe<Scalars['Int']>;
  source?: Maybe<MediaSource>;
  countryOfOrigin?: Maybe<Scalars['CountryCode']>;
  search?: Maybe<Scalars['String']>;
  id_not?: Maybe<Scalars['Int']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  id_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  idMal_not?: Maybe<Scalars['Int']>;
  idMal_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  idMal_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  startDate_greater?: Maybe<Scalars['FuzzyDateInt']>;
  startDate_lesser?: Maybe<Scalars['FuzzyDateInt']>;
  startDate_like?: Maybe<Scalars['String']>;
  endDate_greater?: Maybe<Scalars['FuzzyDateInt']>;
  endDate_lesser?: Maybe<Scalars['FuzzyDateInt']>;
  endDate_like?: Maybe<Scalars['String']>;
  format_in?: Maybe<Array<Maybe<MediaFormat>>>;
  format_not?: Maybe<MediaFormat>;
  format_not_in?: Maybe<Array<Maybe<MediaFormat>>>;
  status_in?: Maybe<Array<Maybe<MediaStatus>>>;
  status_not?: Maybe<MediaStatus>;
  status_not_in?: Maybe<Array<Maybe<MediaStatus>>>;
  episodes_greater?: Maybe<Scalars['Int']>;
  episodes_lesser?: Maybe<Scalars['Int']>;
  duration_greater?: Maybe<Scalars['Int']>;
  duration_lesser?: Maybe<Scalars['Int']>;
  chapters_greater?: Maybe<Scalars['Int']>;
  chapters_lesser?: Maybe<Scalars['Int']>;
  volumes_greater?: Maybe<Scalars['Int']>;
  volumes_lesser?: Maybe<Scalars['Int']>;
  genre_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  genre_not_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  tag_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  tag_not_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  tagCategory_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  tagCategory_not_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  licensedBy_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  averageScore_not?: Maybe<Scalars['Int']>;
  averageScore_greater?: Maybe<Scalars['Int']>;
  averageScore_lesser?: Maybe<Scalars['Int']>;
  popularity_not?: Maybe<Scalars['Int']>;
  popularity_greater?: Maybe<Scalars['Int']>;
  popularity_lesser?: Maybe<Scalars['Int']>;
  source_in?: Maybe<Array<Maybe<MediaSource>>>;
  sort?: Maybe<Array<Maybe<MediaSort>>>;
};

/** Page of data */
export type PageCharactersArgs = {
  id?: Maybe<Scalars['Int']>;
  isBirthday?: Maybe<Scalars['Boolean']>;
  search?: Maybe<Scalars['String']>;
  id_not?: Maybe<Scalars['Int']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  id_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  sort?: Maybe<Array<Maybe<CharacterSort>>>;
};

/** Page of data */
export type PageStaffArgs = {
  id?: Maybe<Scalars['Int']>;
  isBirthday?: Maybe<Scalars['Boolean']>;
  search?: Maybe<Scalars['String']>;
  id_not?: Maybe<Scalars['Int']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  id_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  sort?: Maybe<Array<Maybe<StaffSort>>>;
};

/** Page of data */
export type PageStudiosArgs = {
  id?: Maybe<Scalars['Int']>;
  search?: Maybe<Scalars['String']>;
  id_not?: Maybe<Scalars['Int']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  id_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  sort?: Maybe<Array<Maybe<StudioSort>>>;
};

/** Page of data */
export type PageMediaListArgs = {
  id?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  userName?: Maybe<Scalars['String']>;
  type?: Maybe<MediaType>;
  status?: Maybe<MediaListStatus>;
  mediaId?: Maybe<Scalars['Int']>;
  isFollowing?: Maybe<Scalars['Boolean']>;
  notes?: Maybe<Scalars['String']>;
  startedAt?: Maybe<Scalars['FuzzyDateInt']>;
  completedAt?: Maybe<Scalars['FuzzyDateInt']>;
  compareWithAuthList?: Maybe<Scalars['Boolean']>;
  userId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  status_in?: Maybe<Array<Maybe<MediaListStatus>>>;
  status_not_in?: Maybe<Array<Maybe<MediaListStatus>>>;
  status_not?: Maybe<MediaListStatus>;
  mediaId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mediaId_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  notes_like?: Maybe<Scalars['String']>;
  startedAt_greater?: Maybe<Scalars['FuzzyDateInt']>;
  startedAt_lesser?: Maybe<Scalars['FuzzyDateInt']>;
  startedAt_like?: Maybe<Scalars['String']>;
  completedAt_greater?: Maybe<Scalars['FuzzyDateInt']>;
  completedAt_lesser?: Maybe<Scalars['FuzzyDateInt']>;
  completedAt_like?: Maybe<Scalars['String']>;
  sort?: Maybe<Array<Maybe<MediaListSort>>>;
};

/** Page of data */
export type PageAiringSchedulesArgs = {
  id?: Maybe<Scalars['Int']>;
  mediaId?: Maybe<Scalars['Int']>;
  episode?: Maybe<Scalars['Int']>;
  airingAt?: Maybe<Scalars['Int']>;
  notYetAired?: Maybe<Scalars['Boolean']>;
  id_not?: Maybe<Scalars['Int']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  id_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mediaId_not?: Maybe<Scalars['Int']>;
  mediaId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mediaId_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  episode_not?: Maybe<Scalars['Int']>;
  episode_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  episode_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  episode_greater?: Maybe<Scalars['Int']>;
  episode_lesser?: Maybe<Scalars['Int']>;
  airingAt_greater?: Maybe<Scalars['Int']>;
  airingAt_lesser?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<AiringSort>>>;
};

/** Page of data */
export type PageMediaTrendsArgs = {
  mediaId?: Maybe<Scalars['Int']>;
  date?: Maybe<Scalars['Int']>;
  trending?: Maybe<Scalars['Int']>;
  averageScore?: Maybe<Scalars['Int']>;
  popularity?: Maybe<Scalars['Int']>;
  episode?: Maybe<Scalars['Int']>;
  releasing?: Maybe<Scalars['Boolean']>;
  mediaId_not?: Maybe<Scalars['Int']>;
  mediaId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mediaId_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  date_greater?: Maybe<Scalars['Int']>;
  date_lesser?: Maybe<Scalars['Int']>;
  trending_greater?: Maybe<Scalars['Int']>;
  trending_lesser?: Maybe<Scalars['Int']>;
  trending_not?: Maybe<Scalars['Int']>;
  averageScore_greater?: Maybe<Scalars['Int']>;
  averageScore_lesser?: Maybe<Scalars['Int']>;
  averageScore_not?: Maybe<Scalars['Int']>;
  popularity_greater?: Maybe<Scalars['Int']>;
  popularity_lesser?: Maybe<Scalars['Int']>;
  popularity_not?: Maybe<Scalars['Int']>;
  episode_greater?: Maybe<Scalars['Int']>;
  episode_lesser?: Maybe<Scalars['Int']>;
  episode_not?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<MediaTrendSort>>>;
};

/** Page of data */
export type PageNotificationsArgs = {
  type?: Maybe<NotificationType>;
  resetNotificationCount?: Maybe<Scalars['Boolean']>;
  type_in?: Maybe<Array<Maybe<NotificationType>>>;
};

/** Page of data */
export type PageFollowersArgs = {
  userId: Scalars['Int'];
  sort?: Maybe<Array<Maybe<UserSort>>>;
};

/** Page of data */
export type PageFollowingArgs = {
  userId: Scalars['Int'];
  sort?: Maybe<Array<Maybe<UserSort>>>;
};

/** Page of data */
export type PageActivitiesArgs = {
  id?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  messengerId?: Maybe<Scalars['Int']>;
  mediaId?: Maybe<Scalars['Int']>;
  type?: Maybe<ActivityType>;
  isFollowing?: Maybe<Scalars['Boolean']>;
  hasReplies?: Maybe<Scalars['Boolean']>;
  hasRepliesOrTypeText?: Maybe<Scalars['Boolean']>;
  createdAt?: Maybe<Scalars['Int']>;
  id_not?: Maybe<Scalars['Int']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  id_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  userId_not?: Maybe<Scalars['Int']>;
  userId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  userId_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  messengerId_not?: Maybe<Scalars['Int']>;
  messengerId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  messengerId_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mediaId_not?: Maybe<Scalars['Int']>;
  mediaId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mediaId_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  type_not?: Maybe<ActivityType>;
  type_in?: Maybe<Array<Maybe<ActivityType>>>;
  type_not_in?: Maybe<Array<Maybe<ActivityType>>>;
  createdAt_greater?: Maybe<Scalars['Int']>;
  createdAt_lesser?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<ActivitySort>>>;
};

/** Page of data */
export type PageActivityRepliesArgs = {
  id?: Maybe<Scalars['Int']>;
  activityId?: Maybe<Scalars['Int']>;
};

/** Page of data */
export type PageThreadsArgs = {
  id?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  replyUserId?: Maybe<Scalars['Int']>;
  subscribed?: Maybe<Scalars['Boolean']>;
  categoryId?: Maybe<Scalars['Int']>;
  mediaCategoryId?: Maybe<Scalars['Int']>;
  search?: Maybe<Scalars['String']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  sort?: Maybe<Array<Maybe<ThreadSort>>>;
};

/** Page of data */
export type PageThreadCommentsArgs = {
  id?: Maybe<Scalars['Int']>;
  threadId?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<ThreadCommentSort>>>;
};

/** Page of data */
export type PageReviewsArgs = {
  id?: Maybe<Scalars['Int']>;
  mediaId?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  mediaType?: Maybe<MediaType>;
  sort?: Maybe<Array<Maybe<ReviewSort>>>;
};

/** Page of data */
export type PageRecommendationsArgs = {
  id?: Maybe<Scalars['Int']>;
  mediaId?: Maybe<Scalars['Int']>;
  mediaRecommendationId?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  rating?: Maybe<Scalars['Int']>;
  onList?: Maybe<Scalars['Boolean']>;
  rating_greater?: Maybe<Scalars['Int']>;
  rating_lesser?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<RecommendationSort>>>;
};

/** Page of data */
export type PageLikesArgs = {
  likeableId?: Maybe<Scalars['Int']>;
  type?: Maybe<LikeableType>;
};

export type PageInfo = {
  __typename?: 'PageInfo';
  /** The total number of items */
  total?: Maybe<Scalars['Int']>;
  /** The count on a page */
  perPage?: Maybe<Scalars['Int']>;
  /** The current page */
  currentPage?: Maybe<Scalars['Int']>;
  /** The last page */
  lastPage?: Maybe<Scalars['Int']>;
  /** If there is another page */
  hasNextPage?: Maybe<Scalars['Boolean']>;
};

/** Provides the parsed markdown as html */
export type ParsedMarkdown = {
  __typename?: 'ParsedMarkdown';
  /** The parsed markdown as html */
  html?: Maybe<Scalars['String']>;
};

export type Query = {
  __typename?: 'Query';
  Page?: Maybe<Page>;
  /** Media query */
  Media?: Maybe<Media>;
  /** Media Trend query */
  MediaTrend?: Maybe<MediaTrend>;
  /** Airing schedule query */
  AiringSchedule?: Maybe<AiringSchedule>;
  /** Character query */
  Character?: Maybe<Character>;
  /** Staff query */
  Staff?: Maybe<Staff>;
  /** Media list query */
  MediaList?: Maybe<MediaList>;
  /** Media list collection query, provides list pre-grouped by status & custom lists. User ID and Media Type arguments required. */
  MediaListCollection?: Maybe<MediaListCollection>;
  /** Collection of all the possible media genres */
  GenreCollection?: Maybe<Array<Maybe<Scalars['String']>>>;
  /** Collection of all the possible media tags */
  MediaTagCollection?: Maybe<Array<Maybe<MediaTag>>>;
  /** User query */
  User?: Maybe<User>;
  /** Get the currently authenticated user */
  Viewer?: Maybe<User>;
  /** Notification query */
  Notification?: Maybe<NotificationUnion>;
  /** Studio query */
  Studio?: Maybe<Studio>;
  /** Review query */
  Review?: Maybe<Review>;
  /** Activity query */
  Activity?: Maybe<ActivityUnion>;
  /** Activity reply query */
  ActivityReply?: Maybe<ActivityReply>;
  /** Follow query */
  Following?: Maybe<User>;
  /** Follow query */
  Follower?: Maybe<User>;
  /** Thread query */
  Thread?: Maybe<Thread>;
  /** Comment query */
  ThreadComment?: Maybe<Array<Maybe<ThreadComment>>>;
  /** Recommendation query */
  Recommendation?: Maybe<Recommendation>;
  /** Like query */
  Like?: Maybe<User>;
  /** Provide AniList markdown to be converted to html (Requires auth) */
  Markdown?: Maybe<ParsedMarkdown>;
  AniChartUser?: Maybe<AniChartUser>;
  /** Site statistics query */
  SiteStatistics?: Maybe<SiteStatistics>;
};

export type QueryPageArgs = {
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

export type QueryMediaArgs = {
  id?: Maybe<Scalars['Int']>;
  idMal?: Maybe<Scalars['Int']>;
  startDate?: Maybe<Scalars['FuzzyDateInt']>;
  endDate?: Maybe<Scalars['FuzzyDateInt']>;
  season?: Maybe<MediaSeason>;
  seasonYear?: Maybe<Scalars['Int']>;
  type?: Maybe<MediaType>;
  format?: Maybe<MediaFormat>;
  status?: Maybe<MediaStatus>;
  episodes?: Maybe<Scalars['Int']>;
  duration?: Maybe<Scalars['Int']>;
  chapters?: Maybe<Scalars['Int']>;
  volumes?: Maybe<Scalars['Int']>;
  isAdult?: Maybe<Scalars['Boolean']>;
  genre?: Maybe<Scalars['String']>;
  tag?: Maybe<Scalars['String']>;
  minimumTagRank?: Maybe<Scalars['Int']>;
  tagCategory?: Maybe<Scalars['String']>;
  onList?: Maybe<Scalars['Boolean']>;
  licensedBy?: Maybe<Scalars['String']>;
  averageScore?: Maybe<Scalars['Int']>;
  popularity?: Maybe<Scalars['Int']>;
  source?: Maybe<MediaSource>;
  countryOfOrigin?: Maybe<Scalars['CountryCode']>;
  search?: Maybe<Scalars['String']>;
  id_not?: Maybe<Scalars['Int']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  id_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  idMal_not?: Maybe<Scalars['Int']>;
  idMal_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  idMal_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  startDate_greater?: Maybe<Scalars['FuzzyDateInt']>;
  startDate_lesser?: Maybe<Scalars['FuzzyDateInt']>;
  startDate_like?: Maybe<Scalars['String']>;
  endDate_greater?: Maybe<Scalars['FuzzyDateInt']>;
  endDate_lesser?: Maybe<Scalars['FuzzyDateInt']>;
  endDate_like?: Maybe<Scalars['String']>;
  format_in?: Maybe<Array<Maybe<MediaFormat>>>;
  format_not?: Maybe<MediaFormat>;
  format_not_in?: Maybe<Array<Maybe<MediaFormat>>>;
  status_in?: Maybe<Array<Maybe<MediaStatus>>>;
  status_not?: Maybe<MediaStatus>;
  status_not_in?: Maybe<Array<Maybe<MediaStatus>>>;
  episodes_greater?: Maybe<Scalars['Int']>;
  episodes_lesser?: Maybe<Scalars['Int']>;
  duration_greater?: Maybe<Scalars['Int']>;
  duration_lesser?: Maybe<Scalars['Int']>;
  chapters_greater?: Maybe<Scalars['Int']>;
  chapters_lesser?: Maybe<Scalars['Int']>;
  volumes_greater?: Maybe<Scalars['Int']>;
  volumes_lesser?: Maybe<Scalars['Int']>;
  genre_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  genre_not_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  tag_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  tag_not_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  tagCategory_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  tagCategory_not_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  licensedBy_in?: Maybe<Array<Maybe<Scalars['String']>>>;
  averageScore_not?: Maybe<Scalars['Int']>;
  averageScore_greater?: Maybe<Scalars['Int']>;
  averageScore_lesser?: Maybe<Scalars['Int']>;
  popularity_not?: Maybe<Scalars['Int']>;
  popularity_greater?: Maybe<Scalars['Int']>;
  popularity_lesser?: Maybe<Scalars['Int']>;
  source_in?: Maybe<Array<Maybe<MediaSource>>>;
  sort?: Maybe<Array<Maybe<MediaSort>>>;
};

export type QueryMediaTrendArgs = {
  mediaId?: Maybe<Scalars['Int']>;
  date?: Maybe<Scalars['Int']>;
  trending?: Maybe<Scalars['Int']>;
  averageScore?: Maybe<Scalars['Int']>;
  popularity?: Maybe<Scalars['Int']>;
  episode?: Maybe<Scalars['Int']>;
  releasing?: Maybe<Scalars['Boolean']>;
  mediaId_not?: Maybe<Scalars['Int']>;
  mediaId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mediaId_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  date_greater?: Maybe<Scalars['Int']>;
  date_lesser?: Maybe<Scalars['Int']>;
  trending_greater?: Maybe<Scalars['Int']>;
  trending_lesser?: Maybe<Scalars['Int']>;
  trending_not?: Maybe<Scalars['Int']>;
  averageScore_greater?: Maybe<Scalars['Int']>;
  averageScore_lesser?: Maybe<Scalars['Int']>;
  averageScore_not?: Maybe<Scalars['Int']>;
  popularity_greater?: Maybe<Scalars['Int']>;
  popularity_lesser?: Maybe<Scalars['Int']>;
  popularity_not?: Maybe<Scalars['Int']>;
  episode_greater?: Maybe<Scalars['Int']>;
  episode_lesser?: Maybe<Scalars['Int']>;
  episode_not?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<MediaTrendSort>>>;
};

export type QueryAiringScheduleArgs = {
  id?: Maybe<Scalars['Int']>;
  mediaId?: Maybe<Scalars['Int']>;
  episode?: Maybe<Scalars['Int']>;
  airingAt?: Maybe<Scalars['Int']>;
  notYetAired?: Maybe<Scalars['Boolean']>;
  id_not?: Maybe<Scalars['Int']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  id_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mediaId_not?: Maybe<Scalars['Int']>;
  mediaId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mediaId_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  episode_not?: Maybe<Scalars['Int']>;
  episode_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  episode_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  episode_greater?: Maybe<Scalars['Int']>;
  episode_lesser?: Maybe<Scalars['Int']>;
  airingAt_greater?: Maybe<Scalars['Int']>;
  airingAt_lesser?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<AiringSort>>>;
};

export type QueryCharacterArgs = {
  id?: Maybe<Scalars['Int']>;
  isBirthday?: Maybe<Scalars['Boolean']>;
  search?: Maybe<Scalars['String']>;
  id_not?: Maybe<Scalars['Int']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  id_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  sort?: Maybe<Array<Maybe<CharacterSort>>>;
};

export type QueryStaffArgs = {
  id?: Maybe<Scalars['Int']>;
  isBirthday?: Maybe<Scalars['Boolean']>;
  search?: Maybe<Scalars['String']>;
  id_not?: Maybe<Scalars['Int']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  id_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  sort?: Maybe<Array<Maybe<StaffSort>>>;
};

export type QueryMediaListArgs = {
  id?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  userName?: Maybe<Scalars['String']>;
  type?: Maybe<MediaType>;
  status?: Maybe<MediaListStatus>;
  mediaId?: Maybe<Scalars['Int']>;
  isFollowing?: Maybe<Scalars['Boolean']>;
  notes?: Maybe<Scalars['String']>;
  startedAt?: Maybe<Scalars['FuzzyDateInt']>;
  completedAt?: Maybe<Scalars['FuzzyDateInt']>;
  compareWithAuthList?: Maybe<Scalars['Boolean']>;
  userId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  status_in?: Maybe<Array<Maybe<MediaListStatus>>>;
  status_not_in?: Maybe<Array<Maybe<MediaListStatus>>>;
  status_not?: Maybe<MediaListStatus>;
  mediaId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mediaId_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  notes_like?: Maybe<Scalars['String']>;
  startedAt_greater?: Maybe<Scalars['FuzzyDateInt']>;
  startedAt_lesser?: Maybe<Scalars['FuzzyDateInt']>;
  startedAt_like?: Maybe<Scalars['String']>;
  completedAt_greater?: Maybe<Scalars['FuzzyDateInt']>;
  completedAt_lesser?: Maybe<Scalars['FuzzyDateInt']>;
  completedAt_like?: Maybe<Scalars['String']>;
  sort?: Maybe<Array<Maybe<MediaListSort>>>;
};

export type QueryMediaListCollectionArgs = {
  userId?: Maybe<Scalars['Int']>;
  userName?: Maybe<Scalars['String']>;
  type?: Maybe<MediaType>;
  status?: Maybe<MediaListStatus>;
  notes?: Maybe<Scalars['String']>;
  startedAt?: Maybe<Scalars['FuzzyDateInt']>;
  completedAt?: Maybe<Scalars['FuzzyDateInt']>;
  forceSingleCompletedList?: Maybe<Scalars['Boolean']>;
  chunk?: Maybe<Scalars['Int']>;
  perChunk?: Maybe<Scalars['Int']>;
  status_in?: Maybe<Array<Maybe<MediaListStatus>>>;
  status_not_in?: Maybe<Array<Maybe<MediaListStatus>>>;
  status_not?: Maybe<MediaListStatus>;
  notes_like?: Maybe<Scalars['String']>;
  startedAt_greater?: Maybe<Scalars['FuzzyDateInt']>;
  startedAt_lesser?: Maybe<Scalars['FuzzyDateInt']>;
  startedAt_like?: Maybe<Scalars['String']>;
  completedAt_greater?: Maybe<Scalars['FuzzyDateInt']>;
  completedAt_lesser?: Maybe<Scalars['FuzzyDateInt']>;
  completedAt_like?: Maybe<Scalars['String']>;
  sort?: Maybe<Array<Maybe<MediaListSort>>>;
};

export type QueryMediaTagCollectionArgs = {
  status?: Maybe<Scalars['Int']>;
};

export type QueryUserArgs = {
  id?: Maybe<Scalars['Int']>;
  name?: Maybe<Scalars['String']>;
  isModerator?: Maybe<Scalars['Boolean']>;
  search?: Maybe<Scalars['String']>;
  sort?: Maybe<Array<Maybe<UserSort>>>;
};

export type QueryNotificationArgs = {
  type?: Maybe<NotificationType>;
  resetNotificationCount?: Maybe<Scalars['Boolean']>;
  type_in?: Maybe<Array<Maybe<NotificationType>>>;
};

export type QueryStudioArgs = {
  id?: Maybe<Scalars['Int']>;
  search?: Maybe<Scalars['String']>;
  id_not?: Maybe<Scalars['Int']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  id_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  sort?: Maybe<Array<Maybe<StudioSort>>>;
};

export type QueryReviewArgs = {
  id?: Maybe<Scalars['Int']>;
  mediaId?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  mediaType?: Maybe<MediaType>;
  sort?: Maybe<Array<Maybe<ReviewSort>>>;
};

export type QueryActivityArgs = {
  id?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  messengerId?: Maybe<Scalars['Int']>;
  mediaId?: Maybe<Scalars['Int']>;
  type?: Maybe<ActivityType>;
  isFollowing?: Maybe<Scalars['Boolean']>;
  hasReplies?: Maybe<Scalars['Boolean']>;
  hasRepliesOrTypeText?: Maybe<Scalars['Boolean']>;
  createdAt?: Maybe<Scalars['Int']>;
  id_not?: Maybe<Scalars['Int']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  id_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  userId_not?: Maybe<Scalars['Int']>;
  userId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  userId_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  messengerId_not?: Maybe<Scalars['Int']>;
  messengerId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  messengerId_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mediaId_not?: Maybe<Scalars['Int']>;
  mediaId_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  mediaId_not_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  type_not?: Maybe<ActivityType>;
  type_in?: Maybe<Array<Maybe<ActivityType>>>;
  type_not_in?: Maybe<Array<Maybe<ActivityType>>>;
  createdAt_greater?: Maybe<Scalars['Int']>;
  createdAt_lesser?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<ActivitySort>>>;
};

export type QueryActivityReplyArgs = {
  id?: Maybe<Scalars['Int']>;
  activityId?: Maybe<Scalars['Int']>;
};

export type QueryFollowingArgs = {
  userId: Scalars['Int'];
  sort?: Maybe<Array<Maybe<UserSort>>>;
};

export type QueryFollowerArgs = {
  userId: Scalars['Int'];
  sort?: Maybe<Array<Maybe<UserSort>>>;
};

export type QueryThreadArgs = {
  id?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  replyUserId?: Maybe<Scalars['Int']>;
  subscribed?: Maybe<Scalars['Boolean']>;
  categoryId?: Maybe<Scalars['Int']>;
  mediaCategoryId?: Maybe<Scalars['Int']>;
  search?: Maybe<Scalars['String']>;
  id_in?: Maybe<Array<Maybe<Scalars['Int']>>>;
  sort?: Maybe<Array<Maybe<ThreadSort>>>;
};

export type QueryThreadCommentArgs = {
  id?: Maybe<Scalars['Int']>;
  threadId?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<ThreadCommentSort>>>;
};

export type QueryRecommendationArgs = {
  id?: Maybe<Scalars['Int']>;
  mediaId?: Maybe<Scalars['Int']>;
  mediaRecommendationId?: Maybe<Scalars['Int']>;
  userId?: Maybe<Scalars['Int']>;
  rating?: Maybe<Scalars['Int']>;
  onList?: Maybe<Scalars['Boolean']>;
  rating_greater?: Maybe<Scalars['Int']>;
  rating_lesser?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<RecommendationSort>>>;
};

export type QueryLikeArgs = {
  likeableId?: Maybe<Scalars['Int']>;
  type?: Maybe<LikeableType>;
};

export type QueryMarkdownArgs = {
  markdown: Scalars['String'];
};

/** Media recommendation */
export type Recommendation = {
  __typename?: 'Recommendation';
  /** The id of the recommendation */
  id: Scalars['Int'];
  /** Users rating of the recommendation */
  rating?: Maybe<Scalars['Int']>;
  /** The rating of the recommendation by currently authenticated user */
  userRating?: Maybe<RecommendationRating>;
  /** The media the recommendation is from */
  media?: Maybe<Media>;
  /** The recommended media */
  mediaRecommendation?: Maybe<Media>;
  /** The user that first created the recommendation */
  user?: Maybe<User>;
};

export type RecommendationConnection = {
  __typename?: 'RecommendationConnection';
  edges?: Maybe<Array<Maybe<RecommendationEdge>>>;
  nodes?: Maybe<Array<Maybe<Recommendation>>>;
  /** The pagination information */
  pageInfo?: Maybe<PageInfo>;
};

/** Recommendation connection edge */
export type RecommendationEdge = {
  __typename?: 'RecommendationEdge';
  node?: Maybe<Recommendation>;
};

/** Recommendation rating enums */
export enum RecommendationRating {
  NoRating = 'NO_RATING',
  RateUp = 'RATE_UP',
  RateDown = 'RATE_DOWN',
}

/** Recommendation sort enums */
export enum RecommendationSort {
  Id = 'ID',
  IdDesc = 'ID_DESC',
  Rating = 'RATING',
  RatingDesc = 'RATING_DESC',
}

/** Notification for when new media is added to the site */
export type RelatedMediaAdditionNotification = {
  __typename?: 'RelatedMediaAdditionNotification';
  /** The id of the Notification */
  id: Scalars['Int'];
  /** The type of notification */
  type?: Maybe<NotificationType>;
  /** The id of the new media */
  mediaId: Scalars['Int'];
  /** The notification context text */
  context?: Maybe<Scalars['String']>;
  /** The time the notification was created at */
  createdAt?: Maybe<Scalars['Int']>;
  /** The associated media of the airing schedule */
  media?: Maybe<Media>;
};

export type Report = {
  __typename?: 'Report';
  id: Scalars['Int'];
  reporter?: Maybe<User>;
  reported?: Maybe<User>;
  reason?: Maybe<Scalars['String']>;
  /** When the entry data was created */
  createdAt?: Maybe<Scalars['Int']>;
  cleared?: Maybe<Scalars['Boolean']>;
};

/** A Review that features in an anime or manga */
export type Review = {
  __typename?: 'Review';
  /** The id of the review */
  id: Scalars['Int'];
  /** The id of the review's creator */
  userId: Scalars['Int'];
  /** The id of the review's media */
  mediaId: Scalars['Int'];
  /** For which type of media the review is for */
  mediaType?: Maybe<MediaType>;
  /** A short summary of the review */
  summary?: Maybe<Scalars['String']>;
  /** The main review body text */
  body?: Maybe<Scalars['String']>;
  /** The total user rating of the review */
  rating?: Maybe<Scalars['Int']>;
  /** The amount of user ratings of the review */
  ratingAmount?: Maybe<Scalars['Int']>;
  /** The rating of the review by currently authenticated user */
  userRating?: Maybe<ReviewRating>;
  /** The review score of the media */
  score?: Maybe<Scalars['Int']>;
  /** If the review is not yet publicly published and is only viewable by creator */
  private?: Maybe<Scalars['Boolean']>;
  /** The url for the review page on the AniList website */
  siteUrl?: Maybe<Scalars['String']>;
  /** The time of the thread creation */
  createdAt: Scalars['Int'];
  /** The time of the thread last update */
  updatedAt: Scalars['Int'];
  /** The creator of the review */
  user?: Maybe<User>;
  /** The media the review is of */
  media?: Maybe<Media>;
};

/** A Review that features in an anime or manga */
export type ReviewBodyArgs = {
  asHtml?: Maybe<Scalars['Boolean']>;
};

export type ReviewConnection = {
  __typename?: 'ReviewConnection';
  edges?: Maybe<Array<Maybe<ReviewEdge>>>;
  nodes?: Maybe<Array<Maybe<Review>>>;
  /** The pagination information */
  pageInfo?: Maybe<PageInfo>;
};

/** Review connection edge */
export type ReviewEdge = {
  __typename?: 'ReviewEdge';
  node?: Maybe<Review>;
};

/** Review rating enums */
export enum ReviewRating {
  NoVote = 'NO_VOTE',
  UpVote = 'UP_VOTE',
  DownVote = 'DOWN_VOTE',
}

/** Review sort enums */
export enum ReviewSort {
  Id = 'ID',
  IdDesc = 'ID_DESC',
  Score = 'SCORE',
  ScoreDesc = 'SCORE_DESC',
  Rating = 'RATING',
  RatingDesc = 'RATING_DESC',
  CreatedAt = 'CREATED_AT',
  CreatedAtDesc = 'CREATED_AT_DESC',
  UpdatedAt = 'UPDATED_AT',
  UpdatedAtDesc = 'UPDATED_AT_DESC',
}

/** Feed of mod edit activity */
export type RevisionHistory = {
  __typename?: 'RevisionHistory';
  /** The id of the media */
  id: Scalars['Int'];
  /** The action taken on the objects */
  action?: Maybe<RevisionHistoryAction>;
  /** A JSON object of the fields that changed */
  changes?: Maybe<Scalars['Json']>;
  /** The user who made the edit to the object */
  user?: Maybe<User>;
  /** The media the mod feed entry references */
  media?: Maybe<Media>;
  /** The character the mod feed entry references */
  character?: Maybe<Character>;
  /** The staff member the mod feed entry references */
  staff?: Maybe<Staff>;
  /** The studio the mod feed entry references */
  studio?: Maybe<Studio>;
  /** When the mod feed entry was created */
  createdAt?: Maybe<Scalars['Int']>;
};

/** Revision history actions */
export enum RevisionHistoryAction {
  Create = 'CREATE',
  Edit = 'EDIT',
}

/** A user's list score distribution. */
export type ScoreDistribution = {
  __typename?: 'ScoreDistribution';
  score?: Maybe<Scalars['Int']>;
  /** The amount of list entries with this score */
  amount?: Maybe<Scalars['Int']>;
};

/** Media list scoring type */
export enum ScoreFormat {
  /** An integer from 0-100 */
  Point_100 = 'POINT_100',
  /** A float from 0-10 with 1 decimal place */
  Point_10Decimal = 'POINT_10_DECIMAL',
  /** An integer from 0-10 */
  Point_10 = 'POINT_10',
  /** An integer from 0-5. Should be represented in Stars */
  Point_5 = 'POINT_5',
  /** An integer from 0-3. Should be represented in Smileys. 0 => No Score, 1 => :(, 2 => :|, 3 => :) */
  Point_3 = 'POINT_3',
}

export type SiteStatistics = {
  __typename?: 'SiteStatistics';
  users?: Maybe<SiteTrendConnection>;
  anime?: Maybe<SiteTrendConnection>;
  manga?: Maybe<SiteTrendConnection>;
  characters?: Maybe<SiteTrendConnection>;
  staff?: Maybe<SiteTrendConnection>;
  studios?: Maybe<SiteTrendConnection>;
  reviews?: Maybe<SiteTrendConnection>;
};

export type SiteStatisticsUsersArgs = {
  sort?: Maybe<Array<Maybe<SiteTrendSort>>>;
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

export type SiteStatisticsAnimeArgs = {
  sort?: Maybe<Array<Maybe<SiteTrendSort>>>;
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

export type SiteStatisticsMangaArgs = {
  sort?: Maybe<Array<Maybe<SiteTrendSort>>>;
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

export type SiteStatisticsCharactersArgs = {
  sort?: Maybe<Array<Maybe<SiteTrendSort>>>;
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

export type SiteStatisticsStaffArgs = {
  sort?: Maybe<Array<Maybe<SiteTrendSort>>>;
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

export type SiteStatisticsStudiosArgs = {
  sort?: Maybe<Array<Maybe<SiteTrendSort>>>;
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

export type SiteStatisticsReviewsArgs = {
  sort?: Maybe<Array<Maybe<SiteTrendSort>>>;
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

/** Daily site statistics */
export type SiteTrend = {
  __typename?: 'SiteTrend';
  /** The day the data was recorded (timestamp) */
  date: Scalars['Int'];
  count: Scalars['Int'];
  /** The change from yesterday */
  change: Scalars['Int'];
};

export type SiteTrendConnection = {
  __typename?: 'SiteTrendConnection';
  edges?: Maybe<Array<Maybe<SiteTrendEdge>>>;
  nodes?: Maybe<Array<Maybe<SiteTrend>>>;
  /** The pagination information */
  pageInfo?: Maybe<PageInfo>;
};

/** Site trend connection edge */
export type SiteTrendEdge = {
  __typename?: 'SiteTrendEdge';
  node?: Maybe<SiteTrend>;
};

/** Site trend sort enums */
export enum SiteTrendSort {
  Date = 'DATE',
  DateDesc = 'DATE_DESC',
  Count = 'COUNT',
  CountDesc = 'COUNT_DESC',
  Change = 'CHANGE',
  ChangeDesc = 'CHANGE_DESC',
}

/** Voice actors or production staff */
export type Staff = {
  __typename?: 'Staff';
  /** The id of the staff member */
  id: Scalars['Int'];
  /** The names of the staff member */
  name?: Maybe<StaffName>;
  /**
   * The primary language the staff member dub's in
   * @deprecated Replaced with languageV2
   */
  language?: Maybe<StaffLanguage>;
  /** The primary language of the staff member. Current values: Japanese, English, Korean, Italian, Spanish, Portuguese, French, German, Hebrew, Hungarian, Chinese, Arabic, Filipino, Catalan */
  languageV2?: Maybe<Scalars['String']>;
  /** The staff images */
  image?: Maybe<StaffImage>;
  /** A general description of the staff member */
  description?: Maybe<Scalars['String']>;
  /** The person's primary occupations */
  primaryOccupations?: Maybe<Array<Maybe<Scalars['String']>>>;
  /** The staff's gender. Usually Male, Female, or Non-binary but can be any string. */
  gender?: Maybe<Scalars['String']>;
  dateOfBirth?: Maybe<FuzzyDate>;
  dateOfDeath?: Maybe<FuzzyDate>;
  /** The person's age in years */
  age?: Maybe<Scalars['Int']>;
  /** [startYear, endYear] (If the 2nd value is not present staff is still active) */
  yearsActive?: Maybe<Array<Maybe<Scalars['Int']>>>;
  /** The persons birthplace or hometown */
  homeTown?: Maybe<Scalars['String']>;
  /** If the staff member is marked as favourite by the currently authenticated user */
  isFavourite: Scalars['Boolean'];
  /** If the staff member is blocked from being added to favourites */
  isFavouriteBlocked: Scalars['Boolean'];
  /** The url for the staff page on the AniList website */
  siteUrl?: Maybe<Scalars['String']>;
  /** Media where the staff member has a production role */
  staffMedia?: Maybe<MediaConnection>;
  /** Characters voiced by the actor */
  characters?: Maybe<CharacterConnection>;
  /** Media the actor voiced characters in. (Same data as characters with media as node instead of characters) */
  characterMedia?: Maybe<MediaConnection>;
  /** @deprecated No data available */
  updatedAt?: Maybe<Scalars['Int']>;
  /** Staff member that the submission is referencing */
  staff?: Maybe<Staff>;
  /** Submitter for the submission */
  submitter?: Maybe<User>;
  /** Status of the submission */
  submissionStatus?: Maybe<Scalars['Int']>;
  /** Inner details of submission status */
  submissionNotes?: Maybe<Scalars['String']>;
  /** The amount of user's who have favourited the staff member */
  favourites?: Maybe<Scalars['Int']>;
  /** Notes for site moderators */
  modNotes?: Maybe<Scalars['String']>;
};

/** Voice actors or production staff */
export type StaffDescriptionArgs = {
  asHtml?: Maybe<Scalars['Boolean']>;
};

/** Voice actors or production staff */
export type StaffStaffMediaArgs = {
  sort?: Maybe<Array<Maybe<MediaSort>>>;
  type?: Maybe<MediaType>;
  onList?: Maybe<Scalars['Boolean']>;
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

/** Voice actors or production staff */
export type StaffCharactersArgs = {
  sort?: Maybe<Array<Maybe<CharacterSort>>>;
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

/** Voice actors or production staff */
export type StaffCharacterMediaArgs = {
  sort?: Maybe<Array<Maybe<MediaSort>>>;
  onList?: Maybe<Scalars['Boolean']>;
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

export type StaffConnection = {
  __typename?: 'StaffConnection';
  edges?: Maybe<Array<Maybe<StaffEdge>>>;
  nodes?: Maybe<Array<Maybe<Staff>>>;
  /** The pagination information */
  pageInfo?: Maybe<PageInfo>;
};

/** Staff connection edge */
export type StaffEdge = {
  __typename?: 'StaffEdge';
  node?: Maybe<Staff>;
  /** The id of the connection */
  id?: Maybe<Scalars['Int']>;
  /** The role of the staff member in the production of the media */
  role?: Maybe<Scalars['String']>;
  /** The order the staff should be displayed from the users favourites */
  favouriteOrder?: Maybe<Scalars['Int']>;
};

export type StaffImage = {
  __typename?: 'StaffImage';
  /** The person's image of media at its largest size */
  large?: Maybe<Scalars['String']>;
  /** The person's image of media at medium size */
  medium?: Maybe<Scalars['String']>;
};

/** The primary language of the voice actor */
export enum StaffLanguage {
  /** Japanese */
  Japanese = 'JAPANESE',
  /** English */
  English = 'ENGLISH',
  /** Korean */
  Korean = 'KOREAN',
  /** Italian */
  Italian = 'ITALIAN',
  /** Spanish */
  Spanish = 'SPANISH',
  /** Portuguese */
  Portuguese = 'PORTUGUESE',
  /** French */
  French = 'FRENCH',
  /** German */
  German = 'GERMAN',
  /** Hebrew */
  Hebrew = 'HEBREW',
  /** Hungarian */
  Hungarian = 'HUNGARIAN',
}

/** The names of the staff member */
export type StaffName = {
  __typename?: 'StaffName';
  /** The person's given name */
  first?: Maybe<Scalars['String']>;
  /** The person's middle name */
  middle?: Maybe<Scalars['String']>;
  /** The person's surname */
  last?: Maybe<Scalars['String']>;
  /** The person's first and last name */
  full?: Maybe<Scalars['String']>;
  /** The person's full name in their native language */
  native?: Maybe<Scalars['String']>;
  /** Other names the staff member might be referred to as (pen names) */
  alternative?: Maybe<Array<Maybe<Scalars['String']>>>;
  /** The currently authenticated users preferred name language. Default romaji for non-authenticated */
  userPreferred?: Maybe<Scalars['String']>;
};

/** The names of the staff member */
export type StaffNameInput = {
  /** The person's given name */
  first?: Maybe<Scalars['String']>;
  /** The person's middle name */
  middle?: Maybe<Scalars['String']>;
  /** The person's surname */
  last?: Maybe<Scalars['String']>;
  /** The person's full name in their native language */
  native?: Maybe<Scalars['String']>;
  /** Other names the character might be referred by */
  alternative?: Maybe<Array<Maybe<Scalars['String']>>>;
};

/** Voice actor role for a character */
export type StaffRoleType = {
  __typename?: 'StaffRoleType';
  /** The voice actors of the character */
  voiceActor?: Maybe<Staff>;
  /** Notes regarding the VA's role for the character */
  roleNotes?: Maybe<Scalars['String']>;
  /** Used for grouping roles where multiple dubs exist for the same language. Either dubbing company name or language variant. */
  dubGroup?: Maybe<Scalars['String']>;
};

/** Staff sort enums */
export enum StaffSort {
  Id = 'ID',
  IdDesc = 'ID_DESC',
  Role = 'ROLE',
  RoleDesc = 'ROLE_DESC',
  Language = 'LANGUAGE',
  LanguageDesc = 'LANGUAGE_DESC',
  SearchMatch = 'SEARCH_MATCH',
  Favourites = 'FAVOURITES',
  FavouritesDesc = 'FAVOURITES_DESC',
  /** Order manually decided by moderators */
  Relevance = 'RELEVANCE',
}

/** User's staff statistics */
export type StaffStats = {
  __typename?: 'StaffStats';
  staff?: Maybe<Staff>;
  amount?: Maybe<Scalars['Int']>;
  meanScore?: Maybe<Scalars['Int']>;
  /** The amount of time in minutes the staff member has been watched by the user */
  timeWatched?: Maybe<Scalars['Int']>;
};

/** A submission for a staff that features in an anime or manga */
export type StaffSubmission = {
  __typename?: 'StaffSubmission';
  /** The id of the submission */
  id: Scalars['Int'];
  /** Staff that the submission is referencing */
  staff?: Maybe<Staff>;
  /** The staff submission changes */
  submission?: Maybe<Staff>;
  /** Submitter for the submission */
  submitter?: Maybe<User>;
  /** Status of the submission */
  status?: Maybe<SubmissionStatus>;
  /** Inner details of submission status */
  notes?: Maybe<Scalars['String']>;
  source?: Maybe<Scalars['String']>;
  createdAt?: Maybe<Scalars['Int']>;
};

/** The distribution of the watching/reading status of media or a user's list */
export type StatusDistribution = {
  __typename?: 'StatusDistribution';
  /** The day the activity took place (Unix timestamp) */
  status?: Maybe<MediaListStatus>;
  /** The amount of entries with this status */
  amount?: Maybe<Scalars['Int']>;
};

/** Animation or production company */
export type Studio = {
  __typename?: 'Studio';
  /** The id of the studio */
  id: Scalars['Int'];
  /** The name of the studio */
  name: Scalars['String'];
  /** If the studio is an animation studio or a different kind of company */
  isAnimationStudio: Scalars['Boolean'];
  /** The media the studio has worked on */
  media?: Maybe<MediaConnection>;
  /** The url for the studio page on the AniList website */
  siteUrl?: Maybe<Scalars['String']>;
  /** If the studio is marked as favourite by the currently authenticated user */
  isFavourite: Scalars['Boolean'];
  /** The amount of user's who have favourited the studio */
  favourites?: Maybe<Scalars['Int']>;
};

/** Animation or production company */
export type StudioMediaArgs = {
  sort?: Maybe<Array<Maybe<MediaSort>>>;
  isMain?: Maybe<Scalars['Boolean']>;
  onList?: Maybe<Scalars['Boolean']>;
  page?: Maybe<Scalars['Int']>;
  perPage?: Maybe<Scalars['Int']>;
};

export type StudioConnection = {
  __typename?: 'StudioConnection';
  edges?: Maybe<Array<Maybe<StudioEdge>>>;
  nodes?: Maybe<Array<Maybe<Studio>>>;
  /** The pagination information */
  pageInfo?: Maybe<PageInfo>;
};

/** Studio connection edge */
export type StudioEdge = {
  __typename?: 'StudioEdge';
  node?: Maybe<Studio>;
  /** The id of the connection */
  id?: Maybe<Scalars['Int']>;
  /** If the studio is the main animation studio of the anime */
  isMain: Scalars['Boolean'];
  /** The order the character should be displayed from the users favourites */
  favouriteOrder?: Maybe<Scalars['Int']>;
};

/** Studio sort enums */
export enum StudioSort {
  Id = 'ID',
  IdDesc = 'ID_DESC',
  Name = 'NAME',
  NameDesc = 'NAME_DESC',
  SearchMatch = 'SEARCH_MATCH',
  Favourites = 'FAVOURITES',
  FavouritesDesc = 'FAVOURITES_DESC',
}

/** User's studio statistics */
export type StudioStats = {
  __typename?: 'StudioStats';
  studio?: Maybe<Studio>;
  amount?: Maybe<Scalars['Int']>;
  meanScore?: Maybe<Scalars['Int']>;
  /** The amount of time in minutes the studio's works have been watched by the user */
  timeWatched?: Maybe<Scalars['Int']>;
};

/** Submission sort enums */
export enum SubmissionSort {
  Id = 'ID',
  IdDesc = 'ID_DESC',
}

/** Submission status */
export enum SubmissionStatus {
  Pending = 'PENDING',
  Rejected = 'REJECTED',
  PartiallyAccepted = 'PARTIALLY_ACCEPTED',
  Accepted = 'ACCEPTED',
}

/** User's tag statistics */
export type TagStats = {
  __typename?: 'TagStats';
  tag?: Maybe<MediaTag>;
  amount?: Maybe<Scalars['Int']>;
  meanScore?: Maybe<Scalars['Int']>;
  /** The amount of time in minutes the tag has been watched by the user */
  timeWatched?: Maybe<Scalars['Int']>;
};

/** User text activity */
export type TextActivity = {
  __typename?: 'TextActivity';
  /** The id of the activity */
  id: Scalars['Int'];
  /** The user id of the activity's creator */
  userId?: Maybe<Scalars['Int']>;
  /** The type of activity */
  type?: Maybe<ActivityType>;
  /** The number of activity replies */
  replyCount: Scalars['Int'];
  /** The status text (Markdown) */
  text?: Maybe<Scalars['String']>;
  /** The url for the activity page on the AniList website */
  siteUrl?: Maybe<Scalars['String']>;
  /** If the activity is locked and can receive replies */
  isLocked?: Maybe<Scalars['Boolean']>;
  /** If the currently authenticated user is subscribed to the activity */
  isSubscribed?: Maybe<Scalars['Boolean']>;
  /** The amount of likes the activity has */
  likeCount: Scalars['Int'];
  /** If the currently authenticated user liked the activity */
  isLiked?: Maybe<Scalars['Boolean']>;
  /** The time the activity was created at */
  createdAt: Scalars['Int'];
  /** The user who created the activity */
  user?: Maybe<User>;
  /** The written replies to the activity */
  replies?: Maybe<Array<Maybe<ActivityReply>>>;
  /** The users who liked the activity */
  likes?: Maybe<Array<Maybe<User>>>;
};

/** User text activity */
export type TextActivityTextArgs = {
  asHtml?: Maybe<Scalars['Boolean']>;
};

/** Forum Thread */
export type Thread = {
  __typename?: 'Thread';
  /** The id of the thread */
  id: Scalars['Int'];
  /** The title of the thread */
  title?: Maybe<Scalars['String']>;
  /** The text body of the thread (Markdown) */
  body?: Maybe<Scalars['String']>;
  /** The id of the thread owner user */
  userId: Scalars['Int'];
  /** The id of the user who most recently commented on the thread */
  replyUserId?: Maybe<Scalars['Int']>;
  /** The id of the most recent comment on the thread */
  replyCommentId?: Maybe<Scalars['Int']>;
  /** The number of comments on the thread */
  replyCount?: Maybe<Scalars['Int']>;
  /** The number of times users have viewed the thread */
  viewCount?: Maybe<Scalars['Int']>;
  /** If the thread is locked and can receive comments */
  isLocked?: Maybe<Scalars['Boolean']>;
  /** If the thread is stickied and should be displayed at the top of the page */
  isSticky?: Maybe<Scalars['Boolean']>;
  /** If the currently authenticated user is subscribed to the thread */
  isSubscribed?: Maybe<Scalars['Boolean']>;
  /** The amount of likes the thread has */
  likeCount: Scalars['Int'];
  /** If the currently authenticated user liked the thread */
  isLiked?: Maybe<Scalars['Boolean']>;
  /** The time of the last reply */
  repliedAt?: Maybe<Scalars['Int']>;
  /** The time of the thread creation */
  createdAt: Scalars['Int'];
  /** The time of the thread last update */
  updatedAt: Scalars['Int'];
  /** The owner of the thread */
  user?: Maybe<User>;
  /** The user to last reply to the thread */
  replyUser?: Maybe<User>;
  /** The users who liked the thread */
  likes?: Maybe<Array<Maybe<User>>>;
  /** The url for the thread page on the AniList website */
  siteUrl?: Maybe<Scalars['String']>;
  /** The categories of the thread */
  categories?: Maybe<Array<Maybe<ThreadCategory>>>;
  /** The media categories of the thread */
  mediaCategories?: Maybe<Array<Maybe<Media>>>;
};

/** Forum Thread */
export type ThreadBodyArgs = {
  asHtml?: Maybe<Scalars['Boolean']>;
};

/** A forum thread category */
export type ThreadCategory = {
  __typename?: 'ThreadCategory';
  /** The id of the category */
  id: Scalars['Int'];
  /** The name of the category */
  name: Scalars['String'];
};

/** Forum Thread Comment */
export type ThreadComment = {
  __typename?: 'ThreadComment';
  /** The id of the comment */
  id: Scalars['Int'];
  /** The user id of the comment's owner */
  userId?: Maybe<Scalars['Int']>;
  /** The id of thread the comment belongs to */
  threadId?: Maybe<Scalars['Int']>;
  /** The text content of the comment (Markdown) */
  comment?: Maybe<Scalars['String']>;
  /** The amount of likes the comment has */
  likeCount: Scalars['Int'];
  /** If the currently authenticated user liked the comment */
  isLiked?: Maybe<Scalars['Boolean']>;
  /** The url for the comment page on the AniList website */
  siteUrl?: Maybe<Scalars['String']>;
  /** The time of the comments creation */
  createdAt: Scalars['Int'];
  /** The time of the comments last update */
  updatedAt: Scalars['Int'];
  /** The thread the comment belongs to */
  thread?: Maybe<Thread>;
  /** The user who created the comment */
  user?: Maybe<User>;
  /** The users who liked the comment */
  likes?: Maybe<Array<Maybe<User>>>;
  /** The comment's child reply comments */
  childComments?: Maybe<Scalars['Json']>;
};

/** Forum Thread Comment */
export type ThreadCommentCommentArgs = {
  asHtml?: Maybe<Scalars['Boolean']>;
};

/** Notification for when a thread comment is liked */
export type ThreadCommentLikeNotification = {
  __typename?: 'ThreadCommentLikeNotification';
  /** The id of the Notification */
  id: Scalars['Int'];
  /** The id of the user who liked to the activity */
  userId: Scalars['Int'];
  /** The type of notification */
  type?: Maybe<NotificationType>;
  /** The id of the activity which was liked */
  commentId: Scalars['Int'];
  /** The notification context text */
  context?: Maybe<Scalars['String']>;
  /** The time the notification was created at */
  createdAt?: Maybe<Scalars['Int']>;
  /** The thread that the relevant comment belongs to */
  thread?: Maybe<Thread>;
  /** The thread comment that was liked */
  comment?: Maybe<ThreadComment>;
  /** The user who liked the activity */
  user?: Maybe<User>;
};

/** Notification for when authenticated user is @ mentioned in a forum thread comment */
export type ThreadCommentMentionNotification = {
  __typename?: 'ThreadCommentMentionNotification';
  /** The id of the Notification */
  id: Scalars['Int'];
  /** The id of the user who mentioned the authenticated user */
  userId: Scalars['Int'];
  /** The type of notification */
  type?: Maybe<NotificationType>;
  /** The id of the comment where mentioned */
  commentId: Scalars['Int'];
  /** The notification context text */
  context?: Maybe<Scalars['String']>;
  /** The time the notification was created at */
  createdAt?: Maybe<Scalars['Int']>;
  /** The thread that the relevant comment belongs to */
  thread?: Maybe<Thread>;
  /** The thread comment that included the @ mention */
  comment?: Maybe<ThreadComment>;
  /** The user who mentioned the authenticated user */
  user?: Maybe<User>;
};

/** Notification for when a user replies to your forum thread comment */
export type ThreadCommentReplyNotification = {
  __typename?: 'ThreadCommentReplyNotification';
  /** The id of the Notification */
  id: Scalars['Int'];
  /** The id of the user who create the comment reply */
  userId: Scalars['Int'];
  /** The type of notification */
  type?: Maybe<NotificationType>;
  /** The id of the reply comment */
  commentId: Scalars['Int'];
  /** The notification context text */
  context?: Maybe<Scalars['String']>;
  /** The time the notification was created at */
  createdAt?: Maybe<Scalars['Int']>;
  /** The thread that the relevant comment belongs to */
  thread?: Maybe<Thread>;
  /** The reply thread comment */
  comment?: Maybe<ThreadComment>;
  /** The user who replied to the activity */
  user?: Maybe<User>;
};

/** Thread comments sort enums */
export enum ThreadCommentSort {
  Id = 'ID',
  IdDesc = 'ID_DESC',
}

/** Notification for when a user replies to a subscribed forum thread */
export type ThreadCommentSubscribedNotification = {
  __typename?: 'ThreadCommentSubscribedNotification';
  /** The id of the Notification */
  id: Scalars['Int'];
  /** The id of the user who commented on the thread */
  userId: Scalars['Int'];
  /** The type of notification */
  type?: Maybe<NotificationType>;
  /** The id of the new comment in the subscribed thread */
  commentId: Scalars['Int'];
  /** The notification context text */
  context?: Maybe<Scalars['String']>;
  /** The time the notification was created at */
  createdAt?: Maybe<Scalars['Int']>;
  /** The thread that the relevant comment belongs to */
  thread?: Maybe<Thread>;
  /** The reply thread comment */
  comment?: Maybe<ThreadComment>;
  /** The user who replied to the subscribed thread */
  user?: Maybe<User>;
};

/** Notification for when a thread is liked */
export type ThreadLikeNotification = {
  __typename?: 'ThreadLikeNotification';
  /** The id of the Notification */
  id: Scalars['Int'];
  /** The id of the user who liked to the activity */
  userId: Scalars['Int'];
  /** The type of notification */
  type?: Maybe<NotificationType>;
  /** The id of the thread which was liked */
  threadId: Scalars['Int'];
  /** The notification context text */
  context?: Maybe<Scalars['String']>;
  /** The time the notification was created at */
  createdAt?: Maybe<Scalars['Int']>;
  /** The thread that the relevant comment belongs to */
  thread?: Maybe<Thread>;
  /** The liked thread comment */
  comment?: Maybe<ThreadComment>;
  /** The user who liked the activity */
  user?: Maybe<User>;
};

/** Thread sort enums */
export enum ThreadSort {
  Id = 'ID',
  IdDesc = 'ID_DESC',
  Title = 'TITLE',
  TitleDesc = 'TITLE_DESC',
  CreatedAt = 'CREATED_AT',
  CreatedAtDesc = 'CREATED_AT_DESC',
  UpdatedAt = 'UPDATED_AT',
  UpdatedAtDesc = 'UPDATED_AT_DESC',
  RepliedAt = 'REPLIED_AT',
  RepliedAtDesc = 'REPLIED_AT_DESC',
  ReplyCount = 'REPLY_COUNT',
  ReplyCountDesc = 'REPLY_COUNT_DESC',
  ViewCount = 'VIEW_COUNT',
  ViewCountDesc = 'VIEW_COUNT_DESC',
  IsSticky = 'IS_STICKY',
  SearchMatch = 'SEARCH_MATCH',
}

/** A user */
export type User = {
  __typename?: 'User';
  /** The id of the user */
  id: Scalars['Int'];
  /** The name of the user */
  name: Scalars['String'];
  /** The bio written by user (Markdown) */
  about?: Maybe<Scalars['String']>;
  /** The user's avatar images */
  avatar?: Maybe<UserAvatar>;
  /** The user's banner images */
  bannerImage?: Maybe<Scalars['String']>;
  /** If the authenticated user if following this user */
  isFollowing?: Maybe<Scalars['Boolean']>;
  /** If this user if following the authenticated user */
  isFollower?: Maybe<Scalars['Boolean']>;
  /** If the user is blocked by the authenticated user */
  isBlocked?: Maybe<Scalars['Boolean']>;
  bans?: Maybe<Scalars['Json']>;
  /** The user's general options */
  options?: Maybe<UserOptions>;
  /** The user's media list options */
  mediaListOptions?: Maybe<MediaListOptions>;
  /** The users favourites */
  favourites?: Maybe<Favourites>;
  /** The users anime & manga list statistics */
  statistics?: Maybe<UserStatisticTypes>;
  /** The number of unread notifications the user has */
  unreadNotificationCount?: Maybe<Scalars['Int']>;
  /** The url for the user page on the AniList website */
  siteUrl?: Maybe<Scalars['String']>;
  /** The donation tier of the user */
  donatorTier?: Maybe<Scalars['Int']>;
  /** Custom donation badge text */
  donatorBadge?: Maybe<Scalars['String']>;
  /** The user's moderator roles if they are a site moderator */
  moderatorRoles?: Maybe<Array<Maybe<ModRole>>>;
  /** When the user's account was created. (Does not exist for accounts created before 2020) */
  createdAt?: Maybe<Scalars['Int']>;
  /** When the user's data was last updated */
  updatedAt?: Maybe<Scalars['Int']>;
  /**
   * The user's statistics
   * @deprecated Deprecated. Replaced with statistics field.
   */
  stats?: Maybe<UserStats>;
  /**
   * If the user is a moderator or data moderator
   * @deprecated Deprecated. Replaced with moderatorRoles field.
   */
  moderatorStatus?: Maybe<Scalars['String']>;
};

/** A user */
export type UserAboutArgs = {
  asHtml?: Maybe<Scalars['Boolean']>;
};

/** A user */
export type UserFavouritesArgs = {
  page?: Maybe<Scalars['Int']>;
};

/** A user's activity history stats. */
export type UserActivityHistory = {
  __typename?: 'UserActivityHistory';
  /** The day the activity took place (Unix timestamp) */
  date?: Maybe<Scalars['Int']>;
  /** The amount of activity on the day */
  amount?: Maybe<Scalars['Int']>;
  /** The level of activity represented on a 1-10 scale */
  level?: Maybe<Scalars['Int']>;
};

/** A user's avatars */
export type UserAvatar = {
  __typename?: 'UserAvatar';
  /** The avatar of user at its largest size */
  large?: Maybe<Scalars['String']>;
  /** The avatar of user at medium size */
  medium?: Maybe<Scalars['String']>;
};

export type UserCountryStatistic = {
  __typename?: 'UserCountryStatistic';
  count: Scalars['Int'];
  meanScore: Scalars['Float'];
  minutesWatched: Scalars['Int'];
  chaptersRead: Scalars['Int'];
  mediaIds: Array<Maybe<Scalars['Int']>>;
  country?: Maybe<Scalars['CountryCode']>;
};

export type UserFormatStatistic = {
  __typename?: 'UserFormatStatistic';
  count: Scalars['Int'];
  meanScore: Scalars['Float'];
  minutesWatched: Scalars['Int'];
  chaptersRead: Scalars['Int'];
  mediaIds: Array<Maybe<Scalars['Int']>>;
  format?: Maybe<MediaFormat>;
};

export type UserGenreStatistic = {
  __typename?: 'UserGenreStatistic';
  count: Scalars['Int'];
  meanScore: Scalars['Float'];
  minutesWatched: Scalars['Int'];
  chaptersRead: Scalars['Int'];
  mediaIds: Array<Maybe<Scalars['Int']>>;
  genre?: Maybe<Scalars['String']>;
};

export type UserLengthStatistic = {
  __typename?: 'UserLengthStatistic';
  count: Scalars['Int'];
  meanScore: Scalars['Float'];
  minutesWatched: Scalars['Int'];
  chaptersRead: Scalars['Int'];
  mediaIds: Array<Maybe<Scalars['Int']>>;
  length?: Maybe<Scalars['String']>;
};

/** User data for moderators */
export type UserModData = {
  __typename?: 'UserModData';
  alts?: Maybe<Array<Maybe<User>>>;
  bans?: Maybe<Scalars['Json']>;
  ip?: Maybe<Scalars['Json']>;
  counts?: Maybe<Scalars['Json']>;
  privacy?: Maybe<Scalars['Int']>;
  email?: Maybe<Scalars['String']>;
};

/** A user's general options */
export type UserOptions = {
  __typename?: 'UserOptions';
  /** The language the user wants to see media titles in */
  titleLanguage?: Maybe<UserTitleLanguage>;
  /** Whether the user has enabled viewing of 18+ content */
  displayAdultContent?: Maybe<Scalars['Boolean']>;
  /** Whether the user receives notifications when a show they are watching aires */
  airingNotifications?: Maybe<Scalars['Boolean']>;
  /** Profile highlight color (blue, purple, pink, orange, red, green, gray) */
  profileColor?: Maybe<Scalars['String']>;
  /** Notification options */
  notificationOptions?: Maybe<Array<Maybe<NotificationOption>>>;
  /** The user's timezone offset (Auth user only) */
  timezone?: Maybe<Scalars['String']>;
  /** Minutes between activity for them to be merged together. 0 is Never, Above 2 weeks (20160 mins) is Always. */
  activityMergeTime?: Maybe<Scalars['Int']>;
  /** The language the user wants to see staff and character names in */
  staffNameLanguage?: Maybe<UserStaffNameLanguage>;
};

export type UserReleaseYearStatistic = {
  __typename?: 'UserReleaseYearStatistic';
  count: Scalars['Int'];
  meanScore: Scalars['Float'];
  minutesWatched: Scalars['Int'];
  chaptersRead: Scalars['Int'];
  mediaIds: Array<Maybe<Scalars['Int']>>;
  releaseYear?: Maybe<Scalars['Int']>;
};

export type UserScoreStatistic = {
  __typename?: 'UserScoreStatistic';
  count: Scalars['Int'];
  meanScore: Scalars['Float'];
  minutesWatched: Scalars['Int'];
  chaptersRead: Scalars['Int'];
  mediaIds: Array<Maybe<Scalars['Int']>>;
  score?: Maybe<Scalars['Int']>;
};

/** User sort enums */
export enum UserSort {
  Id = 'ID',
  IdDesc = 'ID_DESC',
  Username = 'USERNAME',
  UsernameDesc = 'USERNAME_DESC',
  WatchedTime = 'WATCHED_TIME',
  WatchedTimeDesc = 'WATCHED_TIME_DESC',
  ChaptersRead = 'CHAPTERS_READ',
  ChaptersReadDesc = 'CHAPTERS_READ_DESC',
  SearchMatch = 'SEARCH_MATCH',
}

/** The language the user wants to see staff and character names in */
export enum UserStaffNameLanguage {
  /** The romanization of the staff or character's native name, with western name ordering */
  RomajiWestern = 'ROMAJI_WESTERN',
  /** The romanization of the staff or character's native name */
  Romaji = 'ROMAJI',
  /** The staff or character's name in their native language */
  Native = 'NATIVE',
}

export type UserStaffStatistic = {
  __typename?: 'UserStaffStatistic';
  count: Scalars['Int'];
  meanScore: Scalars['Float'];
  minutesWatched: Scalars['Int'];
  chaptersRead: Scalars['Int'];
  mediaIds: Array<Maybe<Scalars['Int']>>;
  staff?: Maybe<Staff>;
};

export type UserStartYearStatistic = {
  __typename?: 'UserStartYearStatistic';
  count: Scalars['Int'];
  meanScore: Scalars['Float'];
  minutesWatched: Scalars['Int'];
  chaptersRead: Scalars['Int'];
  mediaIds: Array<Maybe<Scalars['Int']>>;
  startYear?: Maybe<Scalars['Int']>;
};

export type UserStatisticTypes = {
  __typename?: 'UserStatisticTypes';
  anime?: Maybe<UserStatistics>;
  manga?: Maybe<UserStatistics>;
};

export type UserStatistics = {
  __typename?: 'UserStatistics';
  count: Scalars['Int'];
  meanScore: Scalars['Float'];
  standardDeviation: Scalars['Float'];
  minutesWatched: Scalars['Int'];
  episodesWatched: Scalars['Int'];
  chaptersRead: Scalars['Int'];
  volumesRead: Scalars['Int'];
  formats?: Maybe<Array<Maybe<UserFormatStatistic>>>;
  statuses?: Maybe<Array<Maybe<UserStatusStatistic>>>;
  scores?: Maybe<Array<Maybe<UserScoreStatistic>>>;
  lengths?: Maybe<Array<Maybe<UserLengthStatistic>>>;
  releaseYears?: Maybe<Array<Maybe<UserReleaseYearStatistic>>>;
  startYears?: Maybe<Array<Maybe<UserStartYearStatistic>>>;
  genres?: Maybe<Array<Maybe<UserGenreStatistic>>>;
  tags?: Maybe<Array<Maybe<UserTagStatistic>>>;
  countries?: Maybe<Array<Maybe<UserCountryStatistic>>>;
  voiceActors?: Maybe<Array<Maybe<UserVoiceActorStatistic>>>;
  staff?: Maybe<Array<Maybe<UserStaffStatistic>>>;
  studios?: Maybe<Array<Maybe<UserStudioStatistic>>>;
};

export type UserStatisticsFormatsArgs = {
  limit?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<UserStatisticsSort>>>;
};

export type UserStatisticsStatusesArgs = {
  limit?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<UserStatisticsSort>>>;
};

export type UserStatisticsScoresArgs = {
  limit?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<UserStatisticsSort>>>;
};

export type UserStatisticsLengthsArgs = {
  limit?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<UserStatisticsSort>>>;
};

export type UserStatisticsReleaseYearsArgs = {
  limit?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<UserStatisticsSort>>>;
};

export type UserStatisticsStartYearsArgs = {
  limit?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<UserStatisticsSort>>>;
};

export type UserStatisticsGenresArgs = {
  limit?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<UserStatisticsSort>>>;
};

export type UserStatisticsTagsArgs = {
  limit?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<UserStatisticsSort>>>;
};

export type UserStatisticsCountriesArgs = {
  limit?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<UserStatisticsSort>>>;
};

export type UserStatisticsVoiceActorsArgs = {
  limit?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<UserStatisticsSort>>>;
};

export type UserStatisticsStaffArgs = {
  limit?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<UserStatisticsSort>>>;
};

export type UserStatisticsStudiosArgs = {
  limit?: Maybe<Scalars['Int']>;
  sort?: Maybe<Array<Maybe<UserStatisticsSort>>>;
};

/** User statistics sort enum */
export enum UserStatisticsSort {
  Id = 'ID',
  IdDesc = 'ID_DESC',
  Count = 'COUNT',
  CountDesc = 'COUNT_DESC',
  Progress = 'PROGRESS',
  ProgressDesc = 'PROGRESS_DESC',
  MeanScore = 'MEAN_SCORE',
  MeanScoreDesc = 'MEAN_SCORE_DESC',
}

/** A user's statistics */
export type UserStats = {
  __typename?: 'UserStats';
  /** The amount of anime the user has watched in minutes */
  watchedTime?: Maybe<Scalars['Int']>;
  /** The amount of manga chapters the user has read */
  chaptersRead?: Maybe<Scalars['Int']>;
  activityHistory?: Maybe<Array<Maybe<UserActivityHistory>>>;
  animeStatusDistribution?: Maybe<Array<Maybe<StatusDistribution>>>;
  mangaStatusDistribution?: Maybe<Array<Maybe<StatusDistribution>>>;
  animeScoreDistribution?: Maybe<Array<Maybe<ScoreDistribution>>>;
  mangaScoreDistribution?: Maybe<Array<Maybe<ScoreDistribution>>>;
  animeListScores?: Maybe<ListScoreStats>;
  mangaListScores?: Maybe<ListScoreStats>;
  favouredGenresOverview?: Maybe<Array<Maybe<GenreStats>>>;
  favouredGenres?: Maybe<Array<Maybe<GenreStats>>>;
  favouredTags?: Maybe<Array<Maybe<TagStats>>>;
  favouredActors?: Maybe<Array<Maybe<StaffStats>>>;
  favouredStaff?: Maybe<Array<Maybe<StaffStats>>>;
  favouredStudios?: Maybe<Array<Maybe<StudioStats>>>;
  favouredYears?: Maybe<Array<Maybe<YearStats>>>;
  favouredFormats?: Maybe<Array<Maybe<FormatStats>>>;
};

export type UserStatusStatistic = {
  __typename?: 'UserStatusStatistic';
  count: Scalars['Int'];
  meanScore: Scalars['Float'];
  minutesWatched: Scalars['Int'];
  chaptersRead: Scalars['Int'];
  mediaIds: Array<Maybe<Scalars['Int']>>;
  status?: Maybe<MediaListStatus>;
};

export type UserStudioStatistic = {
  __typename?: 'UserStudioStatistic';
  count: Scalars['Int'];
  meanScore: Scalars['Float'];
  minutesWatched: Scalars['Int'];
  chaptersRead: Scalars['Int'];
  mediaIds: Array<Maybe<Scalars['Int']>>;
  studio?: Maybe<Studio>;
};

export type UserTagStatistic = {
  __typename?: 'UserTagStatistic';
  count: Scalars['Int'];
  meanScore: Scalars['Float'];
  minutesWatched: Scalars['Int'];
  chaptersRead: Scalars['Int'];
  mediaIds: Array<Maybe<Scalars['Int']>>;
  tag?: Maybe<MediaTag>;
};

/** The language the user wants to see media titles in */
export enum UserTitleLanguage {
  /** The romanization of the native language title */
  Romaji = 'ROMAJI',
  /** The official english title */
  English = 'ENGLISH',
  /** Official title in it's native language */
  Native = 'NATIVE',
  /** The romanization of the native language title, stylised by media creator */
  RomajiStylised = 'ROMAJI_STYLISED',
  /** The official english title, stylised by media creator */
  EnglishStylised = 'ENGLISH_STYLISED',
  /** Official title in it's native language, stylised by media creator */
  NativeStylised = 'NATIVE_STYLISED',
}

export type UserVoiceActorStatistic = {
  __typename?: 'UserVoiceActorStatistic';
  count: Scalars['Int'];
  meanScore: Scalars['Float'];
  minutesWatched: Scalars['Int'];
  chaptersRead: Scalars['Int'];
  mediaIds: Array<Maybe<Scalars['Int']>>;
  voiceActor?: Maybe<Staff>;
  characterIds: Array<Maybe<Scalars['Int']>>;
};

/** User's year statistics */
export type YearStats = {
  __typename?: 'YearStats';
  year?: Maybe<Scalars['Int']>;
  amount?: Maybe<Scalars['Int']>;
  meanScore?: Maybe<Scalars['Int']>;
};

export type SearchAnimeQueryVariables = Exact<{
  search?: Maybe<Scalars['String']>;
  type?: Maybe<MediaType>;
}>;

export type SearchAnimeQuery = { __typename?: 'Query' } & {
  Media?: Maybe<
    { __typename?: 'Media' } & Pick<
      Media,
      | 'description'
      | 'season'
      | 'seasonYear'
      | 'status'
      | 'averageScore'
      | 'episodes'
      | 'source'
      | 'genres'
      | 'isAdult'
    > & {
        title?: Maybe<
          { __typename?: 'MediaTitle' } & Pick<MediaTitle, 'userPreferred'>
        >;
        coverImage?: Maybe<
          { __typename?: 'MediaCoverImage' } & Pick<MediaCoverImage, 'medium'>
        >;
      }
  >;
};

export type SearchCharQueryVariables = Exact<{
  charName?: Maybe<Scalars['String']>;
}>;

export type SearchCharQuery = { __typename?: 'Query' } & {
  Character?: Maybe<
    { __typename?: 'Character' } & Pick<
      Character,
      'description' | 'age' | 'gender' | 'siteUrl'
    > & {
        name?: Maybe<
          { __typename?: 'CharacterName' } & Pick<
            CharacterName,
            'full' | 'native'
          >
        >;
        image?: Maybe<
          { __typename?: 'CharacterImage' } & Pick<CharacterImage, 'large'>
        >;
        dateOfBirth?: Maybe<
          { __typename?: 'FuzzyDate' } & Pick<
            FuzzyDate,
            'year' | 'month' | 'day'
          >
        >;
      }
  >;
};

export type SearchMangaQueryVariables = Exact<{
  search?: Maybe<Scalars['String']>;
  type?: Maybe<MediaType>;
}>;

export type SearchMangaQuery = { __typename?: 'Query' } & {
  Media?: Maybe<
    { __typename?: 'Media' } & Pick<
      Media,
      | 'description'
      | 'status'
      | 'averageScore'
      | 'genres'
      | 'chapters'
      | 'volumes'
      | 'source'
      | 'isAdult'
    > & {
        title?: Maybe<
          { __typename?: 'MediaTitle' } & Pick<MediaTitle, 'userPreferred'>
        >;
        startDate?: Maybe<
          { __typename?: 'FuzzyDate' } & Pick<
            FuzzyDate,
            'year' | 'month' | 'day'
          >
        >;
        endDate?: Maybe<
          { __typename?: 'FuzzyDate' } & Pick<
            FuzzyDate,
            'year' | 'month' | 'day'
          >
        >;
        coverImage?: Maybe<
          { __typename?: 'MediaCoverImage' } & Pick<MediaCoverImage, 'medium'>
        >;
      }
  >;
};

export const SearchAnimeDocument = gql`
  query searchAnime($search: String, $type: MediaType) {
    Media(search: $search, type: $type) {
      title {
        userPreferred
      }
      description(asHtml: false)
      season
      seasonYear
      status
      averageScore
      episodes
      source
      coverImage {
        medium
      }
      genres
      isAdult
    }
  }
`;
export const SearchCharDocument = gql`
  query searchChar($charName: String) {
    Character(search: $charName) {
      name {
        full
        native
      }
      image {
        large
      }
      description(asHtml: true)
      age
      gender
      dateOfBirth {
        year
        month
        day
      }
      siteUrl
    }
  }
`;
export const SearchMangaDocument = gql`
  query searchManga($search: String, $type: MediaType) {
    Media(search: $search, type: $type) {
      title {
        userPreferred
      }
      description(asHtml: true)
      startDate {
        year
        month
        day
      }
      endDate {
        year
        month
        day
      }
      status
      averageScore
      coverImage {
        medium
      }
      genres
      chapters
      volumes
      source
      isAdult
    }
  }
`;

export type SdkFunctionWrapper = <T>(
  action: (requestHeaders?: Record<string, string>) => Promise<T>,
  operationName: string,
) => Promise<T>;

const defaultWrapper: SdkFunctionWrapper = (action, _operationName) => action();

export function getSdk(
  client: GraphQLClient,
  withWrapper: SdkFunctionWrapper = defaultWrapper,
) {
  return {
    searchAnime(
      variables?: SearchAnimeQueryVariables,
      requestHeaders?: Dom.RequestInit['headers'],
    ): Promise<SearchAnimeQuery> {
      return withWrapper(
        (wrappedRequestHeaders) =>
          client.request<SearchAnimeQuery>(SearchAnimeDocument, variables, {
            ...requestHeaders,
            ...wrappedRequestHeaders,
          }),
        'searchAnime',
      );
    },
    searchChar(
      variables?: SearchCharQueryVariables,
      requestHeaders?: Dom.RequestInit['headers'],
    ): Promise<SearchCharQuery> {
      return withWrapper(
        (wrappedRequestHeaders) =>
          client.request<SearchCharQuery>(SearchCharDocument, variables, {
            ...requestHeaders,
            ...wrappedRequestHeaders,
          }),
        'searchChar',
      );
    },
    searchManga(
      variables?: SearchMangaQueryVariables,
      requestHeaders?: Dom.RequestInit['headers'],
    ): Promise<SearchMangaQuery> {
      return withWrapper(
        (wrappedRequestHeaders) =>
          client.request<SearchMangaQuery>(SearchMangaDocument, variables, {
            ...requestHeaders,
            ...wrappedRequestHeaders,
          }),
        'searchManga',
      );
    },
  };
}
export type Sdk = ReturnType<typeof getSdk>;
<file_sep>export const DEFAULT_SHIRITORI_MIN_LEN = 4;
export const DEFAULT_SHIRITORI_HAND = 5;
<file_sep>export enum BlockingLevel {
  channel = 1,
  guild,
  selfChannel,
  selfGuild,
  none,
}
<file_sep>import { User } from 'discord.js';
import { mock } from 'jest-mock-extended';
import { mocked } from 'ts-jest/utils';
import { prisma } from '../prismaClient';
import { forRibbons, redis } from '../redisClient';
import { UserProvider } from './userProvider';
jest.mock('../redisClient');

const mockRedis = mocked(redis, true);
const userProvider = new UserProvider(prisma, mockRedis);

const mockUserProps = { id: '1', tag: '#1' };
const mockUser = { ...mock<User>(), ...mockUserProps };

afterAll(async () => {
  await prisma.user.deleteMany();
  await prisma.$disconnect();
});

describe('#getRibbons', () => {
  const findUnique = jest.spyOn(prisma.user, 'findUnique');

  afterEach(() => {
    findUnique.mockReset();
    mockRedis.get.mockReset();
  });

  afterAll(() => {
    findUnique.mockRestore();
  });

  test('should search in redis', async () => {
    await userProvider.getRibbons(mockUser as any);
    expect(mockRedis.get).toBeCalledTimes(1);
  });

  describe('found in cache', () => {
    beforeEach(() => {
      mockRedis.get.mockResolvedValueOnce('420');
    });
    test('should not hit db', async () => {
      await userProvider.getRibbons(mockUser as any);
      expect(findUnique).not.toBeCalled();
    });
    test('should return value given by redis', async () => {
      const res = await userProvider.getRibbons(mockUser as any);
      expect(res).toBe(420);
    });
    test('should extend ttl by 60 seconds', async () => {
      await userProvider.getRibbons(mockUser as any);
      expect(mockRedis.expire).toBeCalledWith(forRibbons(mockUser.id), 60);
    });
  });

  describe('not in cache', () => {
    beforeEach(() => {
      mockRedis.get.mockResolvedValueOnce(null);
    });
    test('should hit the db', async () => {
      findUnique.mockResolvedValueOnce({} as any);
      await userProvider.getRibbons(mockUser as any);
      expect(findUnique).toBeCalledTimes(1);
    });
    describe('found in db', () => {
      beforeEach(() => {
        findUnique.mockResolvedValueOnce({ ribbons: 420 } as any);
      });
      test('should return the value from db', async () => {
        const res = await userProvider.getRibbons(mockUser as any);
        expect(res).toBe(420);
      });
      test('should cache the retrieved value', async () => {
        await userProvider.getRibbons(mockUser as any);
        expect(mockRedis.set).toBeCalledWith(
          forRibbons(mockUser.id),
          420,
          'ex',
          60,
        );
      });
    });
    describe('not found in db', () => {
      beforeEach(() => {
        findUnique.mockResolvedValueOnce({} as any);
      });
      test('returns 0', async () => {
        const res = await userProvider.getRibbons(mockUser as any);
        expect(res).toBe(0);
      });
      test('caches 0', async () => {
        await userProvider.getRibbons(mockUser as any);
        expect(mockRedis.set).toBeCalledWith(
          forRibbons(mockUser.id),
          0,
          'ex',
          60,
        );
      });
    });
  });
});

describe('#incrRibbons', () => {
  afterEach(async () => {
    await prisma.user.deleteMany();
  });

  test('should try an error if a negative incrby is given', () => {
    return expect(
      userProvider.incrRibbons(mockUser as any, -10),
    ).rejects.toThrow();
  });
  test('should cache the value from db', async () => {
    const upsert = jest.spyOn(prisma.user, 'upsert');
    upsert.mockResolvedValueOnce({ ribbons: 10 } as any);
    await userProvider.incrRibbons(mockUser as any, 10);
    expect(mockRedis.set).toBeCalledWith(forRibbons(mockUser.id), 10, 'ex', 60);
    upsert.mockRestore();
  });

  describe('user does not exist', () => {
    test('should create the user', async () => {
      const _user = await prisma.user.findUnique({
        where: { userId: mockUser.id },
      });
      expect(_user).toBeNull();
      await userProvider.incrRibbons(mockUser as any, 10);
      const created = await prisma.user.findUnique({
        where: { userId: mockUser.id },
      });
      expect(created).toMatchObject({ userId: '1', ribbons: 10 });
    });
  });
  describe('user exists', () => {
    const data = { userId: '1', tag: '#1', ribbons: 10 };
    beforeEach(async () => {
      await prisma.user.create({ data });
    });
    test('increment the ribbon count', async () => {
      await userProvider.incrRibbons(mockUser as any, 10);
      const res = await prisma.user.findUnique({
        where: { userId: mockUser.id },
      });
      expect(res).toMatchObject({ ...data, ribbons: 20 });
    });
  });
});

describe('#decrRibbons', () => {
  afterEach(async () => {
    await prisma.user.deleteMany();
  });

  test('caches to redis with 60 seconds ttl', async () => {
    const $transaction = jest.spyOn(prisma, '$transaction');
    $transaction.mockResolvedValueOnce([1, { ribbons: 10 }]);
    await userProvider.decrRibbons(mockUser as any, 10);
    expect(mockRedis.set).toBeCalledWith(forRibbons(mockUser.id), 10, 'ex', 60);
    $transaction.mockRestore();
  });

  describe('a negative decrby is given', () => {
    test('throws an error', () => {
      expect(userProvider.decrRibbons(mockUser as any, -1)).rejects.toThrow();
    });
  });

  describe('decrby more than current stock', () => {
    beforeEach(async () => {
      await prisma.user.create({
        data: { userId: mockUser.id, tag: mockUser.tag, ribbons: 10 },
      });
    });
    test('sets to zero', async () => {
      await userProvider.decrRibbons(mockUser as any, 20);
      const res = await prisma.user.findUnique({
        where: { userId: mockUser.id },
        select: { ribbons: true },
      });
      expect(res?.ribbons).toBe(0);
    });
  });

  describe('decrby less than current stock', () => {
    beforeEach(async () => {
      await prisma.user.create({
        data: { userId: mockUser.id, tag: mockUser.tag, ribbons: 10 },
      });
    });
    test('decreases correctly', async () => {
      await userProvider.decrRibbons(mockUser as any, 5);
      const res = await prisma.user.findUnique({
        where: { userId: mockUser.id },
        select: { ribbons: true },
      });
      expect(res?.ribbons).toBe(5);
    });
  });

  describe('user does not exist', () => {
    test('should create the user with 0 ribbons', async () => {
      const _user = await prisma.user.findUnique({
        where: { userId: mockUser.id },
      });
      expect(_user).toBeNull(); // check that its not in the db

      await userProvider.decrRibbons(mockUser as any, 10);
      const res = await prisma.user.findUnique({
        where: { userId: mockUser.id },
        select: { ribbons: true },
      });
      expect(res?.ribbons).toBe(0);
    });
  });
});

describe('#getGlobalTopRibbons', () => {
  beforeAll(async () => {
    await prisma.user.createMany({
      data: [
        { userId: '1', tag: '#1', ribbons: 1 },
        { userId: '2', tag: '#2', ribbons: 2 },
        { userId: '3', tag: '#3', ribbons: 3 },
      ],
    });
  });

  afterAll(async () => {
    await prisma.user.deleteMany();
  });

  test('returns in correct order', async () => {
    const res = await userProvider.getGlobalTopRibbons();
    expect(res).toEqual([
      { tag: '#3', ribbons: 3 },
      { tag: '#2', ribbons: 2 },
      { tag: '#1', ribbons: 1 },
    ]);
  });
});

describe('#getTopRibbonsForUsers', () => {
  const mockUser1 = { ...mock<User>(), id: '1', tag: '#1', ribbons: 1 };
  const mockUser2 = { ...mock<User>(), id: '2', tag: '#2', ribbons: 2 };
  const mockUser3 = { ...mock<User>(), id: '3', tag: '#3', ribbons: 3 };

  beforeAll(async () => {
    await prisma.user.createMany({
      data: [
        { userId: '1', tag: '#1', ribbons: 1 },
        { userId: '2', tag: '#2', ribbons: 2 },
        { userId: '3', tag: '#3', ribbons: 3 },
      ],
    });
  });

  afterAll(async () => {
    await prisma.user.deleteMany();
  });

  test('returns in right order', async () => {
    const res = await userProvider.getTopRibbonsForUsers([
      mockUser1 as any,
      mockUser2,
      mockUser3,
    ]);
    expect(res).toEqual([
      { tag: '#3', ribbons: 3 },
      { tag: '#2', ribbons: 2 },
      { tag: '#1', ribbons: 1 },
    ]);
  });
});
<file_sep>interface GameState {
  gameTitle: string;
  channelId: string;
}

export type { GameState };
<file_sep>import { CmdCategory } from '.prisma/client';
import { Command } from '../../types/command';
import type { IBlogPost } from './utils/types';
import { blogKey, redis } from '../../data/redisClient';
import { baseEmbed } from '../../shared/embeds';
import { white_check_mark } from '../../shared/assets';
import { setCooldown } from '../../lib/cooldownManager';

// TODO: enable anonymous posts
const blogPost = new Command({
  name: 'blog-post',
  aliases: ['bp', 'post'],
  description: 'Post a post on the Chika blog.',
  args: [{ name: 'post', optional: false, multi: true }],
  category: CmdCategory.FUN,
  userCooldown: 10000,

  async execute(context, args) {
    const { channel, author } = context;

    // get the post
    const message = args.join(' ');
    const toStore: IBlogPost = {
      author: author.tag,
      time: Date.now(),
      message,
    };

    // store the post
    redis
      .pipeline()
      .lpush(blogKey, JSON.stringify(toStore))
      .ltrim(blogKey, 0, 50)
      .exec();

    setCooldown(author.id, this.name, this.userCooldown!);
    // ack
    channel.send(
      baseEmbed().setDescription(
        `${white_check_mark} **${author.username}** posted!`,
      ),
    );
  },
});

export default blogPost;
<file_sep>export const chika_detective_png = 'https://i.imgur.com/Ftwr8nw.png';
export const chika_crying_png = 'https://i.imgur.com/yFjZVZN.png';
export const chika_peeking_png = 'https://i.imgur.com/Clk50IV.png';
export const chika_rap_png = 'https://i.imgur.com/AaSi3so.png';
export const chika_pointing_png = 'https://i.imgur.com/OsVZH9j.png';
export const ishigami_cake_png = 'https://i.imgur.com/y7STJ8k.jpg';
export const kaguya_pfp_png = 'https://i.imgur.com/i9Z2E31.png';

export const chika_beating_yu_gif = 'https://i.imgur.com/kYTIHbE.gif';
export const kaguya_sleep_gif = 'https://i.imgur.com/4y1lORB.gif';
export const chika_spin_gif = 'https://i.imgur.com/xMDHNDz.gif';

export const shiritori_rules_jpg = 'https://i.imgur.com/rzgelx8.jpg';
export const balloon_rules_jpg = 'https://i.imgur.com/0aoRzOA.jpg';

export const unknown_png = 'https://i.imgur.com/odkVxpH.png';

export const white_check_mark = '✅';
export const red_cross = '❌';
export const right_arrow = '➡️';
export const left_arrow = '⬅️';

export const ribbon_react = '851852767239864320';
export const ribbon_emoji = '<:chikaribbon:851852767239864320>';

export const chika_pink = '#f2d5da';
export const kaguya_red = '#b32819';
<file_sep>import { CmdCategory } from '@prisma/client';
import type { TextChannel } from 'discord.js';
import { getSdk, MediaType } from '../../generated/anilist';
import { lightErrorEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';
import { sendNotFoundError } from './embeds/errors';
import { mangaInfoEmbed } from './embeds/mangaInfoEmbed';
import { client } from './graphql/aniListClient';

const manga = new Command({
  name: 'manga',
  description: 'Look up info for a manga.',
  args: [{ name: 'title', multi: true }],
  category: CmdCategory.UTILITY,

  async execute(message, args) {
    const { channel } = message;
    const search = args.join(' ');

    const sdk = getSdk(client);
    sdk
      .searchManga({ search, type: MediaType.Manga })
      .then((result) => {
        if (!result.Media) {
          sendNotFoundError(search, channel);
          return;
        }
        if (channel.isText()) {
          const textChannel = channel as TextChannel;
          if (!textChannel.nsfw && result.Media.isAdult) {
            channel.send(
              lightErrorEmbed(
                `This manga is marked as 18+! I can only show this in a NSFW channel.`,
              ),
            );
            return;
          }
        }

        const {
          averageScore,
          coverImage,
          description,
          genres,
          status,
          title,
          source,
          startDate,
          endDate,
          volumes,
          chapters,
        } = result.Media;
        channel.send(
          mangaInfoEmbed({
            coverImage: coverImage?.medium,
            title: title?.userPreferred,
            description: description!,
            status,
            genres,
            averageScore,
            startDate,
            endDate,
            source,
            volumes,
            chapters,
          }),
        );
      })
      .catch((err) => {
        console.error(err);
        sendNotFoundError(search, channel);
      });
  },
});

export default manga;
<file_sep>import { PrismaPromise } from '@prisma/client';
import type { Collection } from 'discord.js';
import { prisma } from '../data/prismaClient';
import type { Command } from '../types/command';

export const seedCommands = async (commands: Collection<string, Command>) => {
  const jobs: PrismaPromise<any>[] = [
    // FIXME: this shit doesn't work
    // it's gonna remove all the relations between Guild and Command
    prisma.$executeRaw(
      `TRUNCATE TABLE "Command", "Arg" RESTART IDENTITY CASCADE`,
    ),
  ];
  commands.forEach(({ name, args, aliases, description, category }) =>
    jobs.push(
      prisma.command.create({
        data: {
          name,
          description,
          category,
          aliases,
          args: {
            createMany: {
              data: args.map(({ name: argName, optional, multi }) => ({
                name: argName,
                optional: !!optional,
                multi: !!multi,
              })),
              skipDuplicates: true,
            },
          },
        },
      }),
    ),
  );
  try {
    await prisma.$transaction(jobs);
    console.log(`commands have been written to ${process.env.DATABASE_URL}`);
  } catch (err) {
    console.error(err);
  }
};
<file_sep>import type { Client } from 'discord.js';
import { activityUpdaters } from '../lib/activityUpdaters';

export function setIntervals(client: Client) {
  client.setInterval(() => {
    const updater =
      activityUpdaters[Math.floor(Math.random() * activityUpdaters.length)];
    updater(client);
  }, 1000 * 60 * 15);
}
<file_sep>import { CmdCategory } from '@prisma/client';
import { TextChannel } from 'discord.js';
import { getSdk, MediaType } from '../../generated/anilist';
import { lightErrorEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';
import { animeInfoEmbed } from './embeds/animeInfoEmbed';
import { sendNotFoundError } from './embeds/errors';
import { client } from './graphql/aniListClient';

const anime = new Command({
  name: 'anime',
  description: 'Look up info for an anime.',
  args: [{ name: 'title', multi: true }],
  category: CmdCategory.UTILITY,

  async execute(message, args) {
    const { channel } = message;
    const search = args.join(' ');

    const sdk = getSdk(client);
    sdk
      .searchAnime({ search, type: MediaType.Anime })
      .then((result) => {
        if (!result.Media) {
          sendNotFoundError(search, channel);
          return;
        }

        if (channel.isText()) {
          const textChannel = channel as TextChannel;
          if (!textChannel.nsfw && result.Media.isAdult) {
            channel.send(
              lightErrorEmbed(
                `This anime is marked as 18+! I can only show this in a NSFW channel.`,
              ),
            );
            return;
          }
        }

        const {
          averageScore,
          coverImage,
          description,
          source,
          episodes,
          genres,
          status,
          season,
          seasonYear,
          title,
        } = result.Media;

        channel.send(
          animeInfoEmbed({
            coverImage: coverImage?.medium,
            title: title?.userPreferred,
            description: description!,
            episodes,
            status,
            genres,
            source,
            averageScore,
            season,
            seasonYear,
          }),
        );
      })
      .catch((err) => {
        console.error(err);
        sendNotFoundError(search, channel);
      });
  },
});

export default anime;
<file_sep>import { CmdCategory } from '@prisma/client';
import { redis } from '../../data/redisClient';
import { baseEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';

const ping = new Command({
  name: 'ping',
  description: 'Say hello to Chika bot.',
  category: CmdCategory.FUN,
  args: [],

  async execute(message) {
    const { channel, author } = message;
    // TODO actually use the redis ping
    channel.send(
      baseEmbed().setDescription(
        `Yo ${author.username}, Love Detective Chika here!`,
      ),
    );
    redis.ping().catch((err) => console.error(err));
  },
});

export default ping;
<file_sep>import _ from 'lodash';
import { mocked } from 'ts-jest/utils';
import {
  DEFAULT_SHIRITORI_HAND,
  DEFAULT_SHIRITORI_MIN_LEN,
} from '../../games/shiritori/utils/defaults';
import { prisma } from '../prismaClient';
import { forShiritoriHand, forShiritoriMinLen, redis } from '../redisClient';
import { ShiritoriProvider } from './shiritoriProvider';
jest.mock('../redisClient');

const mockRedis = mocked(redis, true);
const shiritoriProvider = new ShiritoriProvider(prisma, mockRedis);

afterEach(async () => {
  await prisma.guild.deleteMany();
  await mockRedis.set.mockReset();
  await mockRedis.expire.mockReset();
});

afterAll(() => prisma.$disconnect());

describe('#setMinLen', () => {
  test('throws if negative minLen provided', async () => {
    return expect(shiritoriProvider.setMinLen('1', -1)).rejects.toThrow();
  });

  test('caches to redis', async () => {
    await shiritoriProvider.setMinLen('1', 1);
    expect(mockRedis.set).toBeCalledWith(forShiritoriMinLen('1'), 1, 'ex', 60);
    mockRedis.set.mockClear();
  });

  describe('guild exists in db', () => {
    beforeEach(async () => {
      await prisma.guild.create({
        data: { guildId: '1', shiritori: { create: { minLen: 1 } } },
      });
    });

    test('update the shiritori minLen', async () => {
      await shiritoriProvider.setMinLen('1', 2);
      const res = await prisma.shiritori.findUnique({
        where: { guildId: '1' },
        select: { minLen: true },
      });
      expect(res?.minLen).toBe(2);
    });
  });

  describe('guild does not exist in db', () => {
    test('creates the guild with minLen', async () => {
      const _guild = await prisma.guild.findUnique({ where: { guildId: '1' } });
      expect(_guild).toBeNull(); // check null

      await shiritoriProvider.setMinLen('1', 1);
      const res = await prisma.shiritori.findUnique({
        where: { guildId: '1' },
        select: { minLen: true },
      });
      expect(res?.minLen).toBe(1);
    });
  });
});

describe('#getMinLen', () => {
  test('returns default if no guild id is given', async () => {
    const res = await shiritoriProvider.getMinLen();
    expect(res).toBe(DEFAULT_SHIRITORI_MIN_LEN);
  });

  describe('found in redis', () => {
    beforeEach(() => {
      mockRedis.get.mockResolvedValueOnce('1');
    });
    afterEach(() => {
      mockRedis.get.mockReset();
    });

    test('returns the value from redis as number', async () => {
      const res = await shiritoriProvider.getMinLen('1');
      expect(res).toBe(1);
    });

    test('extends ttl of key', async () => {
      await shiritoriProvider.getMinLen('1');
      expect(mockRedis.expire).toBeCalledWith(forShiritoriMinLen('1'), 60);
    });
  });

  describe('not in redis', () => {
    beforeEach(() => {
      mockRedis.get.mockResolvedValueOnce(null);
    });

    describe('not in db either', () => {
      test('returns the default value', async () => {
        const _sh = await prisma.shiritori.findUnique({
          where: { guildId: '1' },
        });
        expect(_sh).toBeNull(); // check null

        const res = await shiritoriProvider.getMinLen('1');
        expect(res).toBe(DEFAULT_SHIRITORI_MIN_LEN);
      });

      test('caches the default value', async () => {
        await shiritoriProvider.getMinLen('1');
        expect(mockRedis.set).toBeCalledWith(
          forShiritoriMinLen('1'),
          DEFAULT_SHIRITORI_MIN_LEN,
          'ex',
          60,
        );
      });
    });

    describe('found in db', () => {
      beforeEach(async () => {
        await prisma.guild.create({
          data: { guildId: '1', shiritori: { create: { minLen: 2 } } },
        });
      });

      test('returns the value from the db', async () => {
        const res = await shiritoriProvider.getMinLen('1');
        expect(res).toBe(2);
      });

      test('caches the value', async () => {
        await shiritoriProvider.getMinLen('1');
        expect(mockRedis.set).toBeCalledWith(
          forShiritoriMinLen('1'),
          2,
          'ex',
          60,
        );
      });
    });
  });
});

describe('#setHandSize', () => {
  test('throws if hand size less than 1', () => {
    return expect(shiritoriProvider.setHandSize('1', 0)).rejects.toThrow();
  });

  test('caches to redis', async () => {
    const size = _.random(1, 10);
    await shiritoriProvider.setHandSize('1', size);
    expect(mockRedis.set).toBeCalledWith(forShiritoriHand('1'), size, 'ex', 60);
  });

  describe('guild exists with settings', () => {
    const size = _.random(1, 10);
    beforeEach(async () => {
      await prisma.guild.create({
        data: { guildId: '1', shiritori: { create: { handSize: 1 } } },
      });
    });

    test('updates the settings', async () => {
      await shiritoriProvider.setHandSize('1', size);
      const res = await prisma.shiritori.findUnique({
        where: { guildId: '1' },
        select: { handSize: true },
      });
      expect(res?.handSize).toBe(size);
    });
  });

  describe('guild does not exist in settings', () => {
    const size = _.random(1, 10);
    test('creates guild with handSize', async () => {
      const _sh = await prisma.shiritori.findUnique({
        where: { guildId: '1' },
      });
      expect(_sh).toBeNull();

      await shiritoriProvider.setHandSize('1', size);
      const res = await prisma.shiritori.findUnique({
        where: { guildId: '1' },
        select: { handSize: true },
      });
      expect(res?.handSize).toBe(size);
    });
  });
});

describe('#getHandSize', () => {
  describe('found in redis', () => {
    beforeEach(() => {
      mockRedis.get.mockResolvedValueOnce('1');
    });

    test('returns the value from redis as number', async () => {
      const res = await shiritoriProvider.getHandSize('1');
      expect(res).toBe(1);
    });

    test('extends ttl of key', async () => {
      await shiritoriProvider.getHandSize('1');
      expect(mockRedis.expire).toBeCalledWith(forShiritoriHand('1'), 60);
    });
  });

  describe('not in redis', () => {
    beforeEach(() => {
      mockRedis.get.mockResolvedValueOnce(null);
    });

    describe('not in db either', () => {
      test('returns the default value', async () => {
        const _sh = await prisma.shiritori.findUnique({
          where: { guildId: '1' },
        });
        expect(_sh).toBeNull(); // check null

        const res = await shiritoriProvider.getHandSize('1');
        expect(res).toBe(DEFAULT_SHIRITORI_HAND);
      });

      test('caches the default value', async () => {
        await shiritoriProvider.getHandSize('1');
        expect(mockRedis.set).toBeCalledWith(
          forShiritoriHand('1'),
          DEFAULT_SHIRITORI_HAND,
          'ex',
          60,
        );
      });
    });

    describe('found in db', () => {
      beforeEach(async () => {
        await prisma.guild.create({
          data: { guildId: '1', shiritori: { create: { handSize: 2 } } },
        });
      });

      test('returns the value from the db', async () => {
        const res = await shiritoriProvider.getHandSize('1');
        expect(res).toBe(2);
      });

      test('caches the value', async () => {
        await shiritoriProvider.getHandSize('1');
        expect(mockRedis.set).toBeCalledWith(
          forShiritoriHand('1'),
          2,
          'ex',
          60,
        );
      });
    });
  });
});
<file_sep>import type { Message } from 'discord.js';
import { BlockingLevel } from '../../types/blockingLevel';
import type { Game } from '../../types/game';

export const checkAndBlock = (game: Game, message: Message): Promise<void> => {
  const {
    guild,
    channel,
    client: {
      cache: { inGameStates },
    },
  } = message;

  let inGame;

  switch (game.blockingLevel) {
    case BlockingLevel.channel:
      inGame = inGameStates.get(channel.id);
      if (inGame)
        return Promise.reject(
          `There is already a game of **${inGame}** being played in this channel.`,
        );
      inGameStates.set(channel.id, game.displayTitle);
      break;
    case BlockingLevel.guild:
      if (!guild)
        return Promise.reject(
          `${game.displayTitle} can only be played in a guild.`,
        );
      inGame = inGameStates.get(guild.id);
      if (inGame)
        return Promise.reject(
          `There is already a game of **${inGame}** being played in this server.`,
        );
      inGameStates.set(guild.id, game.displayTitle);
      break;
    case BlockingLevel.selfChannel:
      inGame = inGameStates.get(`${game.title}:${channel.id}`);
      if (inGame)
        return Promise.reject(
          `There is already a game of **${inGame}** being played in this channel.`,
        );
      inGameStates.set(`${game.title}:${channel.id}`, game.displayTitle);
      break;
    case BlockingLevel.selfGuild:
      if (!guild)
        return Promise.reject(
          `${game.displayTitle} can only be played in a guild.`,
        );
      inGame = inGameStates.get(`${game.title}:${guild.id}`);
      if (inGame)
        return Promise.reject(
          `There is already a game of **${inGame}** being played in this channel.`,
        );
      inGameStates.set(`${game.title}:${guild.id}`, game.displayTitle);
      break;
    default:
      break;
  }
  return Promise.resolve();
};

export const isGameActive = (game: Game, message: Message): boolean => {
  const {
    client: {
      cache: { inGameStates },
    },
    channel,
    guild,
  } = message;
  let gameActive = false;
  switch (game.blockingLevel) {
    case BlockingLevel.channel:
      gameActive = !!inGameStates.get(channel.id);
      break;
    case BlockingLevel.guild:
      if (!guild)
        throw new Error(`${game.displayTitle} is a guild-level game.`);
      gameActive = !!inGameStates.get(guild.id);
      break;
    case BlockingLevel.selfChannel:
      gameActive = !!inGameStates.get(`${game.title}:${channel.id}`);
      break;
    case BlockingLevel.selfGuild:
      if (!guild)
        throw new Error(`${game.displayTitle} is a guild-level game.`);
      gameActive = !!inGameStates.get(`${game.title}:${guild.id}`);
      break;
    default:
      break;
  }
  return gameActive;
};

export const unblock = (game: Game, message: Message): void => {
  const {
    client: {
      cache: { inGameStates },
    },
    channel,
    guild,
  } = message;

  switch (game.blockingLevel) {
    case BlockingLevel.channel:
      inGameStates.delete(channel.id);
      break;
    case BlockingLevel.guild:
      if (!guild)
        throw new Error(`${game.displayTitle} is a guild-level game.`);
      inGameStates.delete(guild.id);
      break;
    case BlockingLevel.selfChannel:
      inGameStates.delete(`${game.title}:${channel.id}`);
      break;
    case BlockingLevel.selfGuild:
      if (!guild)
        throw new Error(`${game.displayTitle} is a guild-level game.`);
      inGameStates.delete(`${game.title}:${guild.id}`);
      break;
    default:
      break;
  }
};

export const blindUnblock = (message: Message): Promise<boolean> => {
  const {
    channel,
    guild,
    client: {
      cache: { inGameStates },
    },
  } = message;

  if (inGameStates.delete(channel.id)) return Promise.resolve(true);
  if (guild && inGameStates.delete(guild.id)) return Promise.resolve(true);
  return Promise.reject(`I couldn't find an active game.`);
};
<file_sep>import type { Message } from 'discord.js';
import { filterMessage } from '../../../lib/validateMessages';
import { unblock } from '../../utils/manageState';
import type { Balloon } from '../balloon';
import { postGameBalloon } from './postGame';
import type { BalloonState } from './types';

export const createBalloonListener = (state: BalloonState, game: Balloon) => {
  const { players, channelId } = state;
  const listener = (message: Message) => {
    const { content, client } = message;
    if (
      !filterMessage(message, {
        authors: players.map((user) => user),
        channelId,
      })
    ) {
      client.once('message', listener);
      return;
    }

    state.currentVolume += content.length;
    if (state.currentVolume > state.tolerance) {
      unblock(game, message);
      postGameBalloon(message, players);
      return;
    }

    client.once('message', listener);
  };
  return listener;
};
<file_sep>import { CmdCategory } from '@prisma/client';
import { redisQueue } from '../../data/redisClient';
import { lightErrorEmbed, withAuthorEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';
import { sendMusicOnlyInGuild } from './utils/embeds';

const clear = new Command({
  name: 'clear',
  description: 'Clears all tracks from the queue.',
  aliases: ['c'],
  category: CmdCategory.MUSIC,
  args: [],

  async execute(message) {
    const { guild, channel, author } = message;
    if (!guild) {
      sendMusicOnlyInGuild(channel);
      return;
    }

    redisQueue.del(guild.id).then((res) => {
      if (!res) {
        channel.send(lightErrorEmbed('Queue is already empty.'));
        return;
      }
      channel.send(withAuthorEmbed(author).setTitle('Queue has been cleared.'));
    });
  },
});

export default clear;
<file_sep>export function shuffle<T>(arr: T[]): T[] {
  const carr = [...arr];
  let currentIndex = carr.length;

  while (currentIndex) {
    currentIndex -= 1;
    const randomIndex = Math.floor(Math.random() * currentIndex);

    [carr[currentIndex], carr[randomIndex]] = [
      carr[randomIndex],
      carr[currentIndex],
    ];
  }
  return carr;
}
<file_sep>import { CmdCategory } from '@prisma/client';
import { redisQueue as tracks } from '../../data/redisClient';
import { lightErrorEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';
import { sendMusicOnlyInGuild, sendQueue } from './utils/embeds';

// TODO use paginated embed for this

const queue = new Command({
  name: 'queue',
  description: 'Display tracks in the queue.',
  aliases: ['q'],
  args: [],
  category: CmdCategory.MUSIC,

  async execute(message) {
    const { channel, client, guild } = message;
    if (!guild) {
      sendMusicOnlyInGuild(channel);
      return;
    }

    tracks.lrange(guild.id, 0, -1).then((_tracks) => {
      const audioUtils = client.cache.audioUtils.get(guild.id);
      if (_tracks.length === 0 && !audioUtils) {
        channel.send(
          lightErrorEmbed(
            'There are no tracks queued, and nothing is playing now.',
          ),
        );
        return;
      }
      sendQueue(channel, _tracks.map((track) => JSON.parse(track)) || [], {
        nowPlaying: audioUtils?.nowPlaying,
        isPaused: audioUtils?.dispatcher.paused,
        current: audioUtils?.dispatcher.streamTime,
      });
    });
  },
});

export default queue;
<file_sep>import { CmdCategory } from '.prisma/client';
import axios from 'axios';
import {
  Collection,
  DiscordAPIError,
  TextChannel,
  User,
  Webhook,
} from 'discord.js';
import { userProvider } from '../../data/providers/userProvider';
import { forKaguyaInput, forKaguyaResponse } from '../../data/redisClient';
import { kaguya_pfp_png } from '../../shared/assets';
import { sendInsufficientRibbons } from '../../shared/embeds';
import { Command } from '../../types/command';
import {
  cacheInput,
  cacheResponse,
  genChatData,
  handleHuggingFaceError,
  handleWebhookAPIError,
} from './utils/chatbot';

const kaguya = new Command({
  name: 'kaguya',
  aliases: ['ka'],
  args: [{ name: 'your_message', optional: false, multi: true }],
  category: CmdCategory.FUN,
  description: 'Chat with Kaguya, who has an IQ above 3.',

  async execute(message, args) {
    const { channel, author } = message;
    if (!(channel instanceof TextChannel)) return;

    let hooks: Collection<string, Webhook>;
    try {
      hooks = await channel.fetchWebhooks();
    } catch (err) {
      // no webhook permissions, most likely
      handleWebhookAPIError(channel, err);
      return;
    }

    // try and find Kaguya in the collection of webhooks
    let kaguya = hooks.find(
      (wh) =>
        wh.name === 'Kaguya' &&
        wh.owner instanceof User &&
        wh.owner.id === process.env.BOT_USER_ID,
    );

    if (!kaguya) {
      // try to create a webhook for Kaguya
      // TODO: add error handling if the channel has already maxed out
      kaguya = await channel
        .createWebhook('Kaguya', {
          avatar: kaguya_pfp_png,
        })
        .catch((err: DiscordAPIError) => {
          return handleWebhookAPIError(channel, err);
        });
    }
    // we couldn't create a webhook
    if (!kaguya) {
      return;
    }

    const text = args.join(' ');
    const ribbonCost = text.length;
    const ribbonStock = await userProvider.getRibbons(author);
    if (ribbonCost > ribbonStock) {
      sendInsufficientRibbons(channel, ribbonCost, ribbonStock);
      return;
    }

    const data = await genChatData(channel.id, {
      input: forKaguyaInput,
      response: forKaguyaResponse,
      text,
    });

    axios
      .post(process.env.HUGGING_FACE_KAGUYA, data, {
        headers: {
          Authorization: `Bearer ${process.env.HUGGING_FACE_KAGUYA_KEY}`,
        },
      })
      .then((res) => {
        const reply = res.data.generated_text;
        kaguya!.send(`> ${text}\n${reply}`);

        cacheInput(channel.id, text, forKaguyaInput);
        cacheResponse(channel.id, reply, forKaguyaResponse);

        userProvider.decrRibbons(author, ribbonCost);
      })
      .catch((err) => {
        handleHuggingFaceError(kaguya!, err, 'ka', text);
      });
  },
});

export default kaguya;
<file_sep>import type { User } from 'discord.js';
import he from 'he';
import { chika_detective_png } from '../../../shared/assets';
import {
  baseEmbed,
  cryingEmbed,
  lightErrorEmbed,
  withAuthorEmbed,
} from '../../../shared/embeds';
import { GenericChannel } from '../../../types/command';
import { QueueItem } from '../../../types/queue';
import { secToString, stringToSec } from './helpers';

export const toUrlString = (
  title: string,
  link: string,
  truncate?: number,
): string => {
  const decodedTitle = he.decode(title);
  if (!truncate) {
    return `[${decodedTitle
      .replace(/\[/g, '\uFF3B')
      .replace(/\]/g, '\uFF3D')}](${link})`;
  }
  return `[${decodedTitle
    .substring(0, truncate)
    .replace(/\[/g, '\uFF3B')
    .replace(/\]/g, '\uFF3D')} ${
    title.length > truncate ? '...' : ''
  }](${link})`;
};

export const trackLinkAndDuration = (
  title: string,
  url: string,
  duration: string,
  options?: { truncate?: number },
) => `${toUrlString(title, url, options?.truncate)} [${duration}]`;

export const genPlayBar = (current: number, total: string) => {
  // current is in milliseconds
  const totalMillis = stringToSec(total) * 1000;
  const currentMin = secToString(Math.floor(current / 1000));
  const cursor = Math.floor((current / totalMillis) * 29);
  return `${currentMin} ${'-'.repeat(cursor)}o${'-'.repeat(
    29 - cursor,
  )} ${total}`;
};

interface sendNowPlayingOptions {
  streamTime: number;
}

export const sendNowPlaying = async (
  channel: GenericChannel,
  videoData: QueueItem,
  withBar?: sendNowPlayingOptions,
) => {
  const { title, url, duration, thumbnailURL } = videoData;
  channel.send(
    baseEmbed()
      .setTitle('Now playing...')
      .setDescription(
        `${toUrlString(title, url)}${
          withBar ? `\n${genPlayBar(withBar.streamTime, duration)}` : ''
        }`,
      )
      .setThumbnail(thumbnailURL),
  );
};

export const sendNoVideo = async (channel: GenericChannel, searched: string) =>
  channel.send(
    cryingEmbed()
      .setTitle('Sorry...')
      .setDescription(
        `I couldn't find anything at **${searched}**! It might be a restricted video.`,
      ),
  );

export const sendCannotPlay = async (
  channel: GenericChannel,
  title: string,
  url: string,
) =>
  channel.send(
    cryingEmbed()
      .setTitle('Sorry...')
      .setDescription(`I couldn't play [${title}](${url})!`)
      .setFooter(
        "It might be a restricted video, or Chika's music player might be down.",
      ),
  );

export const sendNotInVoiceChannel = async (channel: GenericChannel) =>
  channel.send(lightErrorEmbed('Join a voice channel first!'));

interface sendAddedToQueueOptions {
  videoData: QueueItem;
  author: User;
}

export const sendAddedToQueue = async (
  channel: GenericChannel,
  {
    videoData: { title, url, duration, thumbnailURL },
    author,
  }: sendAddedToQueueOptions,
) =>
  channel.send(
    withAuthorEmbed(author)
      .setTitle('Added to queue!')
      .setDescription(trackLinkAndDuration(title, url, duration))
      .setThumbnail(thumbnailURL),
  );

export const sendMusicOnlyInGuild = async (channel: GenericChannel) =>
  channel.send(lightErrorEmbed('I can only play music for you in a server!'));

export const toListString = (
  arr: string[],
  options?: { noNum?: boolean },
): string => {
  const withCount = arr.map(
    (item, i) => `${options?.noNum ? '' : `\`${i + 1}\``} ${item}`,
  );
  return withCount.join(`\n`);
};

interface sendQueueCurrentTrack {
  nowPlaying?: QueueItem;
  isPaused?: boolean;
  current?: number;
}

export const sendQueue = async (
  channel: GenericChannel,
  tracks: QueueItem[],
  info: sendQueueCurrentTrack,
) => {
  const { current, isPaused, nowPlaying } = info;
  const urlTracks = tracks
    .slice(0, 10)
    .map((track) => toUrlString(track.title, track.url, 40));
  const now = nowPlaying
    ? `${isPaused ? ':pause_button:' : ':arrow_forward:'} ${toUrlString(
        nowPlaying.title,
        nowPlaying.url,
        30,
      )} [${secToString(Math.floor(current! / 1000))} / ${nowPlaying.duration}]`
    : '';
  const partialEmbed = baseEmbed()
    .setTitle('Tracks Queued')
    .setDescription(toListString(urlTracks))
    .setThumbnail(tracks[0]?.thumbnailURL || nowPlaying!.thumbnailURL)
    .setFooter(
      `${tracks.length} ${tracks.length === 1 ? 'track' : 'tracks'} queued ${
        tracks.length > 10 ? '(showing first 10)' : ''
      } | ${
        nowPlaying
          ? `One track ${isPaused ? 'paused' : 'playing'}`
          : `Nothing playing now`
      }`,
    );
  if (nowPlaying) {
    channel.send(partialEmbed.addField(`Now playing`, now));
    return;
  }
  channel.send(partialEmbed);
};

interface sendRepeatProps {
  author: User;
  videoData: QueueItem;
}
export const sendRepeat = async (
  channel: GenericChannel,
  {
    author,
    videoData: { title, url, thumbnailURL, duration },
  }: sendRepeatProps,
) =>
  channel.send(
    withAuthorEmbed(author)
      .setTitle('Track will repeat!')
      .setDescription(trackLinkAndDuration(title, url, duration))
      .setThumbnail(thumbnailURL),
  );

export const sendSearchResults = (
  channel: GenericChannel,
  res: QueueItem[],
) => {
  const urlTitles = res.map((videoData) =>
    toUrlString(videoData.title, videoData.url, 36),
  );

  channel.send(
    baseEmbed()
      .setTitle('I found these tracks:')
      .setDescription(toListString(urlTitles))
      .setThumbnail(chika_detective_png)
      .setFooter("Send me the track's number and I'll add it to the queue!"),
  );
};

export const sendNoVoicePermissions = (channel: GenericChannel) =>
  channel.send(
    lightErrorEmbed(`Please give me permissions to join your voice chat.`),
  );

export const sendFinishedAllTracks = (channel: GenericChannel) =>
  channel.send(
    baseEmbed().setDescription(`All requested tracks have finished playing.`),
  );
<file_sep>import { CmdCategory } from '.prisma/client';
import { shiritoriProvider } from '../../data/providers/shiritoriProvider';
import {
  baseEmbed,
  lightErrorEmbed,
  sendNotInGuild,
} from '../../shared/embeds';
import { Command } from '../../types/command';

const shiritoriHand = new Command({
  name: 'shiritori-hand',
  aliases: ['sh-hand'],
  args: [{ name: 'size', optional: true, multi: false }],
  category: CmdCategory.GAMES,
  description: 'Check or set the initial hand size in Shiritori.',
  async execute(ctx, args) {
    const { channel, guild } = ctx;
    if (!guild) {
      sendNotInGuild(channel);
      return;
    }

    const [_newSize] = args;
    if (!_newSize) {
      const currHand = await shiritoriProvider.getHandSize(guild.id);
      channel.send(
        baseEmbed().setDescription(
          `Current starting hand size: **${currHand}**`,
        ),
      );
      return;
    }

    const newSize = parseInt(_newSize, 10);
    if (Number.isNaN(newSize)) {
      channel.send(lightErrorEmbed('Please give me a valid number!'));
      return;
    }
    if (newSize < 0) {
      channel.send(lightErrorEmbed('Haha how does that even work.'));
      return;
    }
    if (newSize > 12) {
      channel.send(lightErrorEmbed('I can only issue a maximum of 12 cards!'));
      return;
    }
    await shiritoriProvider.setHandSize(guild.id, newSize);
    channel.send(
      baseEmbed().setDescription(
        `The hand size for Shiritori has been set to **${newSize}**!
			This will apply on the next game.`,
      ),
    );
  },
});

export default shiritoriHand;
<file_sep>import { CmdCategory } from '@prisma/client';
import { red_cross, white_check_mark } from '../../shared/assets';
import { baseEmbed, sendNotInGuild } from '../../shared/embeds';
import { Command } from '../../types/command';

const poll = new Command({
  name: 'poll',
  args: [{ name: 'question', multi: true }],
  category: CmdCategory.UTILITY,
  description: 'Show your enthusiasm for democracy.',

  aliases: ['vote'],
  async execute(message, args) {
    const { guild, channel, author } = message;
    if (!guild) {
      sendNotInGuild(channel);
      return;
    }
    const pollQn = args.join(' ');
    channel
      .send(
        baseEmbed()
          .setThumbnail(author.displayAvatarURL())
          .setDescription(`**${author.username}** wants to know...`)
          .addField(pollQn, `Please react to vote!`),
      )
      .then(async (_message) => {
        await _message
          .react(white_check_mark)
          .then(() => _message.react(red_cross));
      });
  },
});

export default poll;
<file_sep>import { CmdCategory } from '@prisma/client';
import { Command } from '../../types/command';
import { avatarEmbed } from './utils/embeds';

const avatar = new Command({
  name: 'avatar',
  description: "Retrieves users' avatars.",
  category: CmdCategory.FUN,
  args: [{ name: 'user', optional: true, multi: true }],

  async execute(message) {
    const { mentions, author, channel } = message;
    if (!mentions.users.size) {
      channel.send(avatarEmbed(author));
      return;
    }
    mentions.users.forEach((user) => {
      channel.send(avatarEmbed(user));
    });
  },
});

export default avatar;
<file_sep>import type { Client } from 'discord.js';
import { Collection } from 'discord.js';
import fs from 'fs';
import path from 'path';
import { Command } from '../types/command';

export const loadCommands = (): Client['commands'] => {
  const commands = new Collection<string, Command>();
  const commandFolders = fs.readdirSync(path.join(__dirname, '..', 'commands'));
  commandFolders.forEach((folder) => {
    const commandFiles = fs
      .readdirSync(path.join(__dirname, '..', 'commands', folder))
      .filter((filename) => filename.endsWith('.js'));
    commandFiles.forEach((filename) => {
      const command: Command =
        // eslint-disable-next-line @typescript-eslint/no-var-requires
        require(`../commands/${folder}/${filename}`).default;
      commands.set(command.name, command);
    });
  });
  return commands;
};
<file_sep>import { Message, TextChannel } from 'discord.js';
import { mock } from 'jest-mock-extended';
import { redis } from '../data/redisClient';
import { Command } from '../types/command';
import * as cooldownManager from './cooldownManager';
import { isOnCooldown } from './validateCooldowns';
jest.mock('../data/redisClient');

afterAll(() => {
  return redis.quit();
});

describe('#isOnCooldown', () => {
  const mockMessage = mock<Message>();
  mockMessage.channel = mock<TextChannel>();

  const channelSendSpy = jest.spyOn(mockMessage.channel, 'send');
  const getCooldownSpy = jest.spyOn(cooldownManager, 'getCooldown');

  beforeEach(() => {
    getCooldownSpy.mockClear();
    channelSendSpy.mockClear();
  });

  describe('if command has no cooldown set', () => {
    const mockCommand = mock<Command>();
    mockCommand.channelCooldown = undefined;
    mockCommand.userCooldown = undefined;
    it('should return false', () => {
      return isOnCooldown(mockMessage, mockCommand).then((res) => {
        expect(res).toBe(false);
      });
    });
    it('should not call #getCooldown', () => {
      return isOnCooldown(mockMessage, mockCommand).then(() => {
        expect(getCooldownSpy).toBeCalledTimes(0);
      });
    });
  });

  describe('if command has channel cooldown', () => {
    const mockCommand = mock<Command>();
    mockCommand.channelCooldown = 10000;
    mockCommand.userCooldown = undefined;
    it('should call #getCooldown once', () => {
      return isOnCooldown(mockMessage, mockCommand).then(() => {
        expect(getCooldownSpy).toBeCalledTimes(1);
      });
    });

    describe('and got ttl > 0', () => {
      it('should return true', () => {
        getCooldownSpy.mockResolvedValueOnce(1000);
        return isOnCooldown(mockMessage, mockCommand).then((res) => {
          expect(res).toBe(true);
        });
      });
      it('should call channel#send once', () => {
        getCooldownSpy.mockResolvedValueOnce(1000);
        return isOnCooldown(mockMessage, mockCommand).then(() => {
          expect(channelSendSpy).toBeCalledTimes(1);
        });
      });
    });

    describe('got ttl 0', () => {
      it('should return false', () => {
        getCooldownSpy.mockResolvedValueOnce(0);
        return isOnCooldown(mockMessage, mockCommand).then((res) => {
          expect(res).toBe(false);
        });
      });
      it('should not call channel#send', () => {
        return isOnCooldown(mockMessage, mockCommand).then(() => {
          expect(channelSendSpy).toBeCalledTimes(0);
        });
      });
    });
  });

  describe('if command has user cooldown', () => {
    const mockCommand = mock<Command>();
    mockCommand.userCooldown = 10000;
    mockCommand.channelCooldown = undefined;
    it('should call getCooldown once', () => {
      return isOnCooldown(mockMessage, mockCommand).then(() => {
        expect(getCooldownSpy).toBeCalledTimes(1);
      });
    });

    describe('and found ttl', () => {
      it('should return true', () => {
        getCooldownSpy.mockResolvedValueOnce(1000);
        return isOnCooldown(mockMessage, mockCommand).then((res) => {
          expect(res).toBe(true);
        });
      });
      it('should call channel#send once', () => {
        getCooldownSpy.mockResolvedValueOnce(1000);
        return isOnCooldown(mockMessage, mockCommand).then(() => {
          expect(channelSendSpy).toBeCalledTimes(1);
        });
      });
    });
  });
});
<file_sep>import _ from 'lodash';
import {
  Maybe,
  MediaSeason,
  MediaSource,
  MediaStatus,
} from '../../../generated/anilist';
import { unknown_png } from '../../../shared/assets';
import { baseEmbed } from '../../../shared/embeds';
import { parseHtml } from '../../../lib/typography';

interface animeEmbedParams {
  title: string | null | undefined;
  description: string | null | undefined;
  status: MediaStatus | null | undefined;
  averageScore: number | null | undefined;
  episodes: number | null | undefined;
  coverImage: string | null | undefined;
  genres: Maybe<string>[] | null | undefined;
  season: MediaSeason | null | undefined;
  seasonYear: number | null | undefined;
  source: MediaSource | null | undefined;
}

export const animeInfoEmbed = (info: animeEmbedParams) => {
  const {
    coverImage,
    title,
    description,
    episodes,
    status,
    genres,
    source,
    averageScore,
    season,
    seasonYear,
  } = info;
  return baseEmbed()
    .setThumbnail(coverImage || unknown_png)
    .setTitle(title)
    .setDescription(
      description ? parseHtml(description) : `*No description for this anime.*`,
    )
    .addFields([
      {
        name: ':film_frames: Status',
        value: status ? _.capitalize(status.replace(/_/g, ' ')) : '?',
        inline: true,
      },
      {
        name: ':cherry_blossom: Season',
        value:
          seasonYear && season
            ? `${_.startCase(season.toLowerCase())} ${seasonYear}`
            : '?',
        inline: true,
      },
    ])
    .addField(':shinto_shrine: Genres', genres?.join(', ') || ':question:')
    .addFields([
      {
        name: ':tv: Episodes',
        value: `${episodes || '?'}`,
        inline: true,
      },
      {
        name: ':star: Rating',
        value: averageScore ? `${averageScore}/100` : '?',
        inline: true,
      },
      {
        name: ':ramen: Sauce',
        value: source
          ? _.startCase(source.replace(/_/g, ' ').toLowerCase())
          : '?',
        inline: true,
      },
    ]);
};
<file_sep>import axios from 'axios';

export const checkWord = (word: string): Promise<boolean> => {
  const uri = `http://api.datamuse.com/words?sp=${word}&max=1`;
  // TODO catch errors properly
  return axios
    .get(uri)
    .then((response) => response.data.length === 1)
    .catch(() => false);
};
<file_sep>import type {
  Message,
  MessageEmbed,
  MessageReaction,
  Snowflake,
  User,
} from 'discord.js';
import { Collection } from 'discord.js';
import { toListString } from '../commands/music/utils/embeds';
import { unblock } from '../games/utils/manageState';
import { red_cross, white_check_mark } from '../shared/assets';
import {
  baseEmbed,
  detectiveEmbed,
  genericErrorEmbed,
  lightErrorEmbed,
} from '../shared/embeds';
import { BlockingLevel } from './blockingLevel';
import type { GenericChannel } from './command';
import { stripIndents } from 'common-tags';

interface collectPlayersOptions {
  onTimeoutAccept: (players: Collection<Snowflake, User>) => void;
  onTimeoutReject?: () => void;
}

interface getOpponentResponseOptions {
  onAccept: () => void;
  onReject?: () => void;
}

export abstract class Game {
  abstract title: string;
  abstract displayTitle: string;
  abstract minPlayerCount: number;
  abstract maxPlayerCount: number;
  abstract rules: MessageEmbed;
  abstract sessionDuration: number; // in milliseconds
  abstract pregame(message: Message): void;
  abstract blockingLevel: BlockingLevel;

  collectPlayers(message: Message, options: collectPlayersOptions) {
    const { author, channel } = message;
    const { onTimeoutAccept, onTimeoutReject } = options;

    const partialEmbed = baseEmbed()
      .setDescription(
        `**${author.username}** wants to play **${this.displayTitle}**!`,
      )
      .addField(`To join`, `React to this message with the green check-mark.`);

    channel
      .send(
        this.minPlayerCount === 1
          ? partialEmbed
          : partialEmbed.setFooter(
              `We need ${this.minPlayerCount - 1} more ${
                this.minPlayerCount - 1 > 1 ? 'players' : 'player'
              } to start.`,
            ),
      )
      .then(async (_message) => {
        await _message.react(white_check_mark);

        return _message
          .awaitReactions(
            (reaction: MessageReaction, user: User) =>
              user.id !== author.id && reaction.emoji.name === white_check_mark,
            { maxUsers: this.maxPlayerCount - 1, time: 10000 },
          )
          .then(async (collected) => {
            const reactors =
              (await collected.first()?.users.fetch()) ||
              new Collection<Snowflake, User>();

            const players = reactors
              .filter((user) => !user.bot)
              .set(author.id, author);
            if (players.size < this.minPlayerCount) {
              return Promise.reject(new Error(`Insufficient players.`));
            }
            return Promise.resolve(players);
          });
      })
      .then(
        (players) => onTimeoutAccept(players),
        () => {
          unblock(this, message);
          if (onTimeoutReject) {
            onTimeoutReject();
            return;
          }
          channel.send(
            lightErrorEmbed(
              `We don't have enough players to start **${this.displayTitle}**.`,
            ),
          );
        },
      )
      .catch((err) => {
        console.error(err);
        channel.send(genericErrorEmbed());
      });
  }

  getOpponentResponse(
    message: Message,
    opponent: User,
    options: getOpponentResponseOptions,
  ) {
    const { channel, author } = message;
    const { onAccept, onReject } = options;
    channel
      .send(
        `${opponent.toString()}! **${
          author.username
        }** has challenged you to a game of **${
          this.displayTitle
        }**!\nDo you accept this challenge?`,
      )
      .then(async (_message) => {
        await _message
          .react(white_check_mark)
          .then(() => _message.react(red_cross));

        _message
          .awaitReactions(
            (reaction: MessageReaction, user: User) =>
              user.id === opponent.id &&
              (reaction.emoji.name === white_check_mark ||
                reaction.emoji.name === red_cross),
            { time: 10000, max: 1 },
          )
          .then((collected) => {
            const reaction = collected.first();
            switch (reaction?.emoji.name) {
              case white_check_mark:
                onAccept();
                break;
              case red_cross:
                unblock(this, message);
                if (onReject) {
                  onReject();
                } else {
                  channel.send(
                    `**${opponent.username}** does not want to play ${this.displayTitle}`,
                  );
                }
                break;
              default:
                unblock(this, message);
                channel.send(
                  lightErrorEmbed(`No response from **${opponent.username}**.`),
                );
                break;
            }
          });
      })
      .catch((err) => {
        console.error(err);
        channel.send(genericErrorEmbed());
      });
  }

  async sendParticipants(
    channel: GenericChannel,
    participants: User[],
    options?: { startsInMessage: string },
  ) {
    const players = participants.map((user) => user.toString());
    return channel.send(
      detectiveEmbed()
        .setTitle(this.displayTitle)
        .addField(`Players:`, toListString(players))
        .addField(
          `More info`,
          stripIndents`
          To review the rules of **${
            this.displayTitle
          }**, use \`${`${this.title}-rules`}\`.
        \`${'stop-game'}\` will stop the game at anytime.
        
        ${options?.startsInMessage || "I'll start the game in 5 seconds!"}
        `,
        ),
    );
  }
}
<file_sep>import type { Client, Collection, Guild, Message, User } from 'discord.js';
import { balloonProvider } from '../../data/providers/balloonProvider';
import { balloon_rules_jpg, ribbon_emoji } from '../../shared/assets';
import { baseEmbed } from '../../shared/embeds';
import { BlockingLevel } from '../../types/blockingLevel';
import { GenericChannel } from '../../types/command';
import { Game } from '../../types/game';
import { createBalloonListener } from './utils/listener';
import { BalloonState } from './utils/types';

export class Balloon extends Game {
  title = 'balloon';

  displayTitle = 'Balloon';

  minPlayerCount = 2;

  maxPlayerCount = 6;

  sessionDuration = 1000 * 60 * 10;

  blockingLevel = BlockingLevel.guild;

  pregame(message: Message) {
    const { channel, client, guild } = message;
    this.collectPlayers(message, {
      onTimeoutAccept: (players) => {
        this.sendParticipants(
          channel,
          players.map((user) => user),
          {
            startsInMessage: `Carry on with your lives. ʕ•ᴥ•ʔ
            Each time you send something, the balloon gets pumped. You'll know when it pops.`,
          },
        );
        this.startGame(players, { channel, client, guild: guild! });
      },
    });
  }

  async startGame(
    players: Collection<string, User>,
    meta: { channel: GenericChannel; guild: Guild; client: Client },
  ) {
    const { channel, client, guild } = meta;
    const max = await balloonProvider.getMax(guild.id);
    const min = await balloonProvider.getMin(guild.id);
    const tolerance = Math.floor(Math.random() * (max - min) + min);
    const initState: BalloonState = {
      gameTitle: this.title,
      channelId: channel.id,
      currentVolume: 0,
      tolerance,
      players,
    };

    client.once('message', createBalloonListener(initState, this));
  }

  rules = baseEmbed()
    .setTitle('Balloon :balloon:')
    .setImage(balloon_rules_jpg)
    .setFooter(`chapter 91 page 3`)
    .addFields([
      {
        name: 'How it works',
        value: `
        Chika hands you a pump and a 
        balloon. Each character you send
        pumps the balloon by a certain
        amount.

        The balloon will pop! once it
        reaches its limit, which is randomly
        chosen each round. The player
        who pops it loses!`,
      },
      {
        name: 'Player count',
        value: `${this.minPlayerCount} - ${this.maxPlayerCount} players`,
      },
      {
        name: 'Losing',
        value: `
        The player who pops the balloon
        will pay ${ribbon_emoji} to all other players.`,
      },
      {
        name: 'Options',
        value: `
        You may override the default
        balloon's volume range for 
        the server:
        \`balloon-min\`
        Set lower bound for volume.
        \`balloon-max\`
        Set upper bound for volume.`,
      },
    ]);
}
<file_sep>import { CmdCategory } from '@prisma/client';
import { helpExtraInfo } from '../../init/fullHelpEmbed';
import { badCommandsEmbed, baseEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';

const help = new Command({
  name: 'help',
  description: 'Get a list of all commands, or look up specific commands.',
  args: [{ name: 'commands', optional: true, multi: true }],
  category: CmdCategory.UTILITY,
  aliases: ['h'],

  async execute({ channel, client: { commands, commandsHelp } }, args) {
    if (!args.length || /^all$/i.test(args[0])) {
      // send a list of all commands
      channel.send(commandsHelp).then(() => channel.send(helpExtraInfo));
      return;
    }

    // return info for a specific command
    const unknownCommands: string[] = [];
    args.forEach((arg) => {
      const match = commands.find(
        (command) => command.name === arg || !!command.aliases?.includes(arg),
      );
      if (match) {
        const embed = baseEmbed()
          .addField(match.name, match.description)
          .addField('Usage', `\`${match.usage}\``, true);
        if (match.aliases) {
          const preTag = match.aliases.map((alias) => `\`${alias}\``);
          embed.addField('Aliases', preTag.join(', '), true);
        }
        channel.send(embed);
        return;
      }
      unknownCommands.push(arg);
    });
    if (unknownCommands.length) {
      channel.send(badCommandsEmbed(...unknownCommands));
    }
  },
});

export default help;
<file_sep>import type { Message, Snowflake, User } from 'discord.js';
import { Collection } from 'discord.js';
import { shiritoriProvider } from '../../data/providers/shiritoriProvider';
import { shiritori_rules_jpg } from '../../shared/assets';
import { baseEmbed, lightErrorEmbed } from '../../shared/embeds';
import { BlockingLevel } from '../../types/blockingLevel';
import { Game } from '../../types/game';
import { sendTaggedSelfError } from '../utils/embeds';
import { genInitialCards } from './utils/cards';
import { shiritoriPlayerCardsEmbed } from './utils/embeds';
import { createOnceShiritoriListener } from './utils/listener';
import type { ShiritoriState } from './utils/types';

interface startShiritoriParams {
  p1: User;
  p2: User;
}

export class Shiritori extends Game {
  title = 'shiritori';

  displayTitle = 'Shiritori';

  minPlayerCount = 2;

  maxPlayerCount = 2;

  sessionDuration = 1000 * 60 * 10; // 10 min in ms

  blockingLevel = BlockingLevel.channel;

  pregame(message: Message) {
    const { channel, mentions, author } = message;
    const taggedOpponent = mentions.users.first();

    if (
      process.env.NODE_ENV !== 'development' &&
      taggedOpponent?.id === author.id
    ) {
      sendTaggedSelfError(channel);
      return;
    }

    if (!taggedOpponent) {
      this.collectPlayers(message, {
        onTimeoutAccept: (players) => {
          const [p1, p2] = players.map((player) => player);
          this.startGame(message, { p1, p2 });
        },
      });
      return;
    }

    this.getOpponentResponse(message, taggedOpponent, {
      onAccept: () =>
        this.startGame(message, { p1: author, p2: taggedOpponent }),
      onReject: () =>
        channel.send(
          lightErrorEmbed(
            `**${taggedOpponent.username}** does not want to play Shiritori.`,
          ),
        ),
    });
  }

  async startGame(message: Message, { p1, p2 }: startShiritoriParams) {
    const { channel, client, guild } = message;
    const size = await shiritoriProvider.getHandSize(guild!.id);
    const { p1Cards, p2Cards, startingChar } = genInitialCards(size);

    const cards = new Collection<Snowflake, string[]>();
    cards.set(p1.id, p1Cards);
    cards.set(p2.id, p2Cards);

    const minLen = await shiritoriProvider.getMinLen(message.guild?.id);

    const initState: ShiritoriState = {
      gameTitle: 'shiritori',
      cards,
      channelId: channel.id,
      p1,
      p2,
      startingChar,
      minLen,
    };

    this.sendParticipants(channel, [p1, p2], {
      startsInMessage: `Your words must have at least **${minLen}** ${
        minLen === 1 ? 'letter' : 'letters'
      }.
      I'll reveal the first card in 5 seconds!`,
    }).then(() => channel.send(shiritoriPlayerCardsEmbed(initState)));

    setTimeout(() => {
      client.once('message', createOnceShiritoriListener(initState, this)); // register the new listener
      channel.send(`:regional_indicator_${initState.startingChar}:`);
    }, 5000);
  }

  rules = baseEmbed()
    .setTitle('Shiritori :u55b6:')
    .addFields([
      {
        name: 'How it works',
        value: `
      Each player is issued 5 cards.
      
      You must form a word which a) starts 
      with the last played card, b) ends
      on one of your cards, and c) be at
      least 4 characters long.
      
      The game will start with a random 
      card.
      `,
      },
      {
        name: 'Player count',
        value: '2',
      },
      {
        name: 'To win',
        value: `Be the first to clear all 5 cards!`,
      },
      {
        name: 'Options',
        value: `
        You can override the default
        minimum word length, as well as the
        number of cards issued to each
        player.
        \`sh-min\`
        Set the minimum word length.
        \`sh-hand\`
        Set the initial card count.`,
      },
    ])
    .setImage(shiritori_rules_jpg)
    .setFooter(`chapter 188 page 4`);
}
<file_sep>import { CmdCategory } from '@prisma/client';
import { shiritoriGame } from '../../games/shiritori';
import { checkAndBlock } from '../../games/utils/manageState';
import { lightErrorEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';

const shiritori = new Command({
  name: 'shiritori',
  category: CmdCategory.GAMES,
  description: 'Play a round of Shiritori.',
  aliases: ['sh'],
  args: [{ name: 'an_opponent', optional: true }],

  async execute(message) {
    const taggedCount = message.mentions.users.size;
    if (taggedCount && taggedCount > 1) {
      message.channel.send(
        lightErrorEmbed(
          `**Shiritori** is a 2-player game!\nPlease tag only one other player.`,
        ),
      );
      return;
    }

    checkAndBlock(shiritoriGame, message).then(
      () => shiritoriGame.pregame(message),
      (err) => message.channel.send(lightErrorEmbed(err)),
    );
  },
});

export default shiritori;
<file_sep>import { baseEmbed } from '../../../shared/embeds';
import type { ShiritoriState } from './types';
import { genCardsString } from './cards';

export const shiritoriPlayerCardsEmbed = ({ p1, p2, cards }: ShiritoriState) =>
  baseEmbed()
    .setTitle('Your cards!')
    .addFields([
      {
        name: `**${p1.username}**'s cards`,
        value: genCardsString(cards.get(p1.id)!),
      },
      {
        name: `**${p2.username}**'s cards`,
        value: genCardsString(cards.get(p2.id)!),
      },
    ]);
<file_sep>import { stripIndents } from 'common-tags';
import { DiscordAPIError, Webhook } from 'discord.js';
import { redis, redisPrefixer } from '../../../data/redisClient';
import { lightErrorEmbed } from '../../../shared/embeds';
import { GenericChannel } from '../../../types/command';
import {
  chatbotLimitError,
  chatbotLoadingError,
  chatbotTimeoutError,
} from './embeds';
import type { ChatbotChar, ChatbotInput } from './types';

interface IChatDataOptions {
  input: redisPrefixer;
  response: redisPrefixer;
  text: string;
}

export async function genChatData(
  channelId: string,
  options: IChatDataOptions,
): Promise<string> {
  const { input, response, text } = options;
  const generated_responses = (
    await redis.lrange(response(channelId), 0, -1)
  ).reverse();
  const past_user_inputs = (
    await redis.lrange(input(channelId), 0, -1)
  ).reverse();

  const data: ChatbotInput = {
    inputs: { text, generated_responses, past_user_inputs },
  };
  return JSON.stringify(data);
}

export function cacheInput(
  channelId: string,
  text: string,
  prefixFn: redisPrefixer,
) {
  const key = prefixFn(channelId);
  redis.pipeline().lpush(key, text).ltrim(key, 0, 2).expire(key, 120).exec();
}

export function cacheResponse(
  channelId: string,
  response: string,
  prefixFn: redisPrefixer,
) {
  const key = prefixFn(channelId);
  redis
    .pipeline()
    .lpush(key, response.replace(/[^\w\s]/gi, ''))
    .ltrim(key, 0, 2)
    .expire(key, 120)
    .exec();
}

export function handleHuggingFaceError(
  channelOrWebhook: GenericChannel | Webhook,
  err: any,
  char: ChatbotChar,
  text?: string,
) {
  console.error(err);
  if (err.response?.data?.error?.includes(`is currently loading`)) {
    if (channelOrWebhook instanceof Webhook) {
      channelOrWebhook.send(text && `> ${text}`, {
        embeds: [chatbotLoadingError(char)],
      });
    } else {
      channelOrWebhook.send(text && `> ${text}`, {
        embed: chatbotLoadingError(char),
      });
    }
  } else if (err.response?.data?.error?.includes(`API usage limit reached`)) {
    if (channelOrWebhook instanceof Webhook) {
      channelOrWebhook.send(text && `> ${text}`, {
        embeds: [chatbotLimitError(char)],
      });
    } else {
      channelOrWebhook.send(text && `> ${text}`, {
        embed: chatbotLimitError(char),
      });
    }
  } else {
    if (channelOrWebhook instanceof Webhook) {
      channelOrWebhook.send(text && `> ${text}`, {
        embeds: [chatbotTimeoutError(char)],
      });
    } else {
      channelOrWebhook.send(text && `> ${text}`, {
        embed: chatbotTimeoutError(char),
      });
    }
  }
}

export function handleWebhookAPIError(
  channel: GenericChannel,
  err: DiscordAPIError,
) {
  if (err.code === 50013) {
    // we don't have permissions to manage webhooks
    channel.send(
      lightErrorEmbed(
        stripIndents`
				I need permissions to manage webhooks in this channel!
				Otherwise I can't invite Kaguya here...`,
      ),
    );
    return undefined;
  }
  if (err.code === 30007) {
    // they maxed out the number of webhooks
    channel.send(
      lightErrorEmbed(stripIndents`
    This channel has already hit the maximum number of webhooks.
    I can't invite Kaguya to this channel...`),
    );
    return undefined;
  }
  console.error(err);
  return undefined;
}
<file_sep>-- CreateTable
CREATE TABLE "_CommandToGuild" (
    "A" INTEGER NOT NULL,
    "B" INTEGER NOT NULL
);

-- CreateIndex
CREATE UNIQUE INDEX "_CommandToGuild_AB_unique" ON "_CommandToGuild"("A", "B");

-- CreateIndex
CREATE INDEX "_CommandToGuild_B_index" ON "_CommandToGuild"("B");

-- AddForeignKey
ALTER TABLE "_CommandToGuild" ADD FOREIGN KEY ("A") REFERENCES "Command"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "_CommandToGuild" ADD FOREIGN KEY ("B") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
<file_sep>import { mocked } from 'ts-jest/utils';
import { DEFAULT_PREFIX } from '../../shared/constants';
import { prisma } from '../prismaClient';
import { forPrefix, redis } from '../redisClient';
import { GuildProvider } from './guildProvider';
jest.mock('../redisClient');

const mockRedis = mocked(redis, true);

const guildProvider = new GuildProvider(prisma, mockRedis);

afterAll(() => prisma.$disconnect());

describe('#getPrefix', () => {
  const findUnique = jest.spyOn(prisma.guild, 'findUnique');

  afterAll(() => {
    findUnique.mockRestore();
  });

  describe('prefix is cached', () => {
    beforeEach(() => {
      mockRedis.get.mockResolvedValue('prefix');
    });
    afterEach(() => {
      mockRedis.expire.mockClear();
      findUnique.mockClear();
    });

    mockRedis.get.mockResolvedValue('prefix');
    test('prisma should not be called', async () => {
      await guildProvider.getPrefix('guild-id');
      expect(findUnique).not.toBeCalled();
    });
    test('redis should extend ttl', async () => {
      await guildProvider.getPrefix('guild-id');
      expect(mockRedis.expire).toBeCalledWith(forPrefix('guild-id'), 60);
    });
    test('should return the cached prefix', async () => {
      const prefix = await guildProvider.getPrefix('guild-id');
      expect(prefix).toBe('prefix');
    });
  });

  describe('prefix is not cached', () => {
    beforeEach(() => {
      mockRedis.get.mockResolvedValue(null);
    });
    afterEach(() => {
      mockRedis.set.mockClear();
      findUnique.mockClear();
    });

    test('prisma should be called', async () => {
      findUnique.mockResolvedValueOnce({} as any);
      await guildProvider.getPrefix('guild-id');
      expect(findUnique).toBeCalledTimes(1);
    });

    describe('and got result from db', () => {
      beforeEach(() => {
        findUnique.mockResolvedValue({
          prefix: 'prefix',
        } as any);
      });

      test('should cache in redis', async () => {
        await guildProvider.getPrefix('guild-id');
        expect(mockRedis.set).toBeCalledWith(
          forPrefix('guild-id'),
          'prefix',
          'ex',
          60,
        );
      });
      test('returns the prefix', async () => {
        const prefix = await guildProvider.getPrefix('guild-id');
        expect(prefix).toBe('prefix');
      });
    });
    describe('and could not get results from db', () => {
      beforeEach(() => {
        findUnique.mockResolvedValue({} as any); // no prefix set
      });

      test('returns the default prefix', async () => {
        const prefix = await guildProvider.getPrefix('guild-id');
        expect(prefix).toBe(DEFAULT_PREFIX);
      });
      test('caches the default prefix in redis', async () => {
        await guildProvider.getPrefix('guild-id');
        expect(mockRedis.set).toBeCalledWith(
          forPrefix('guild-id'),
          DEFAULT_PREFIX,
          'ex',
          60,
        );
      });
    });
  });
});

describe('#setPrefix', () => {
  const guildId = 'g';
  const upsert = jest.spyOn(prisma.guild, 'upsert');

  afterEach(async () => {
    await prisma.guild.deleteMany();
    upsert.mockClear();
  });

  afterAll(() => {
    upsert.mockRestore();
  });

  test('caches in redis', async () => {
    await guildProvider.setPrefix('guild-id', 'p');
    expect(mockRedis.set).toBeCalledWith(forPrefix('guild-id'), 'p', 'ex', 60);
  });
  test('uses upsert', async () => {
    await guildProvider.setPrefix('guild-id', 'p');
    expect(upsert).toBeCalledTimes(1);
  });

  describe('guild does not exist', () => {
    test('inserts the guild', async () => {
      // ensure the guild does not exist
      const _guild = await prisma.guild.findUnique({ where: { guildId } });
      expect(_guild).toBeNull();

      await guildProvider.setPrefix(guildId, 'p');
      const guild = await prisma.guild.findUnique({
        where: { guildId },
        select: { prefix: true },
      });
      expect(guild?.prefix).toBe('p');
    });
  });

  describe('guild exists', () => {
    test('updates the guild', async () => {
      await prisma.guild.create({ data: { guildId, prefix: 'a' } });
      await guildProvider.setPrefix(guildId, 'b');
      const res = await prisma.guild.findUnique({
        where: { guildId },
        select: { prefix: true },
      });
      expect(res?.prefix).toBe('b');
    });
  });
});
<file_sep>import { CmdCategory } from '@prisma/client';
import { userProvider } from '../../data/providers/userProvider';
import { genericErrorEmbed, lightErrorEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';
import { MAX_TAKE } from './utils/defaults';
import { sendExceededMaxTake, sendTop } from './utils/embeds';

const globalTop = new Command({
  name: 'global-top',
  description: `Track down the top 1%.`,
  args: [{ name: 'take', optional: true, multi: false }],
  category: CmdCategory.CURRENCY,
  aliases: ['gt'],

  async execute(message, args) {
    const { channel } = message;
    let take;
    const [count] = args;
    if (count) {
      const _take = parseInt(count, 10);
      if (Number.isNaN(_take)) {
        channel.send(lightErrorEmbed('Gimme a number yo.'));
        return;
      }
      if (_take > MAX_TAKE) {
        sendExceededMaxTake(channel);
        return;
      }
      take = _take;
    }
    const top = await userProvider.getGlobalTopRibbons(take);
    if (!top) {
      channel.send(genericErrorEmbed());
      return;
    }
    sendTop(channel, top);
  },
});

export default globalTop;
<file_sep>import { CmdCategory } from '@prisma/client';
import { lightErrorEmbed, withAuthorEmbed } from '../../shared/embeds';
import { Command } from '../../types/command';
import { sendMusicOnlyInGuild, trackLinkAndDuration } from './utils/embeds';

const pause = new Command({
  name: 'pause',
  aliases: ['stop'],
  description: 'Pause the current playback.',
  category: CmdCategory.MUSIC,
  args: [],

  async execute(message) {
    const { client, channel, guild, author } = message;
    if (!guild) {
      sendMusicOnlyInGuild(channel);
      return;
    }

    const audioUtils = client.cache.audioUtils.get(guild.id);
    if (!audioUtils?.dispatcher) {
      channel.send(
        lightErrorEmbed("There isn't anything playing right now..."),
      );
      return;
    }
    if (audioUtils.dispatcher.paused) {
      channel.send(lightErrorEmbed('Playback is already paused!'));
      return;
    }

    audioUtils.dispatcher.pause();
    const { title, url, duration } = audioUtils.nowPlaying!;
    channel.send(
      withAuthorEmbed(author)
        .setTitle(`:pause_button: Paused`)
        .setDescription(trackLinkAndDuration(title, url, duration)),
    );
  },
});

export default pause;
<file_sep>import { PrismaClient } from '.prisma/client';
import { Redis } from 'ioredis';
import {
  DEFAULT_MAX_BALLOON,
  DEFAULT_MIN_BALLOON,
} from '../../games/balloon/utils/defaults';
import { prisma as prismaClient } from '../prismaClient';
import {
  forBalloonMax,
  forBalloonMin,
  redis as redisClient,
} from '../redisClient';

export class BalloonProvider {
  constructor(
    private readonly prisma: PrismaClient,
    private readonly redis: Redis,
  ) {}

  async setMin(minVol: number, guildId: string) {
    // validation to be done in app code
    if (minVol < 0) throw new Error('Received negative volume.');
    await this.prisma.guild
      .upsert({
        where: { guildId },
        update: {
          balloon: {
            upsert: { update: { minVol }, create: { minVol } },
          },
        },
        create: {
          guildId,
          balloon: {
            connectOrCreate: { create: { minVol }, where: { guildId } },
          },
        },
      })
      .then(() => {
        this.redis.set(forBalloonMin(guildId), minVol, 'ex', 60);
      });
  }

  async getMin(guildId: string) {
    const ping = await this.redis.get(forBalloonMin(guildId));
    if (ping) {
      this.redis.expire(forBalloonMin(guildId), 60);
      return parseInt(ping, 10);
    }
    return this.prisma.balloon
      .findUnique({
        where: { guildId },
        select: { minVol: true },
      })
      .then((res) => {
        const minVol = res?.minVol || DEFAULT_MIN_BALLOON;
        this.redis.set(forBalloonMin(guildId), minVol, 'ex', 60);
        return minVol;
      });
  }

  async setMax(maxVol: number, guildId: string) {
    // validation to be done in app code
    if (maxVol < 0) throw new Error('Received negative volume.');
    await this.prisma.guild
      .upsert({
        where: { guildId },
        update: {
          balloon: {
            upsert: { update: { maxVol }, create: { maxVol } },
          },
        },
        create: {
          guildId,
          balloon: {
            create: { maxVol },
          },
        },
      })
      .then(() => {
        this.redis.set(forBalloonMax(guildId), maxVol, 'ex', 60);
      });
  }

  async getMax(guildId: string) {
    const ping = await this.redis.get(forBalloonMax(guildId));
    if (ping) {
      this.redis.expire(forBalloonMax(guildId), 60);
      return parseInt(ping, 10);
    }
    return this.prisma.balloon
      .findUnique({
        where: { guildId },
        select: { maxVol: true },
      })
      .then((res) => {
        const maxVol = res?.maxVol || DEFAULT_MAX_BALLOON;
        this.redis.set(forBalloonMax(guildId), maxVol, 'ex', 60);
        return maxVol;
      });
  }
}

export const balloonProvider = new BalloonProvider(prismaClient, redisClient);
<file_sep>import { unknown_png } from '../../../shared/assets';
import { FuzzyDate } from '../../../generated/anilist';
import { baseEmbed } from '../../../shared/embeds';
import { parseHtml, truncate, wrapText } from '../../../lib/typography';
import { parseFuzzyDate } from './mangaInfoEmbed';

interface charInfoEmbedParams {
  englishName: string | null | undefined;
  japName: string | null | undefined;
  image: string | null | undefined;
  age: string | null | undefined;
  gender: string | null | undefined;
  description: string | null | undefined;
  dateOfBirth: FuzzyDate | null | undefined;
  siteUrl: string | null | undefined;
}

export const charInfoEmbed = (info: charInfoEmbedParams) => {
  const {
    englishName,
    japName,
    image,
    gender,
    age,
    dateOfBirth,
    description,
    siteUrl,
  } = info;
  let genderEmoji;
  switch (gender?.toLowerCase()) {
    case 'male':
      genderEmoji = ':male_sign:';
      break;
    case 'female':
      genderEmoji = ':female_sign:';
      break;
    default:
      genderEmoji = '';
      break;
  }

  return baseEmbed()
    .setTitle(`${englishName || '?'} ${genderEmoji}\n${japName || '?'}`)
    .setDescription(characterDescription(description, siteUrl))
    .addFields([
      { name: ':calendar: Age', value: age || '?', inline: true },
      {
        name: ':cake: Birthday',
        value: parseFuzzyDate(dateOfBirth),
        inline: true,
      },
    ])
    .setImage(image || unknown_png);
};

function characterDescription(
  desc: string | null | undefined,
  siteUrl: string | null | undefined,
) {
  if (!desc) return '*No description for this character.*';
  let body = truncate(parseHtml(desc), 70, true);
  if (!siteUrl || body.length === desc.length) return body;
  body = `${wrapText(`${body} [read more]`)}(${siteUrl})`;
  return body;
}
<file_sep>-- CreateEnum
CREATE TYPE "CmdCategory" AS ENUM ('CURRENCY', 'FUN', 'GAMES', 'MUSIC', 'UTILITY');

-- CreateTable
CREATE TABLE "Arg" (
    "id" SERIAL NOT NULL,
    "name" TEXT NOT NULL,
    "optional" BOOLEAN NOT NULL,
    "multi" BOOLEAN NOT NULL,
    "commandId" INTEGER,

    PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "Command" (
    "id" SERIAL NOT NULL,
    "name" TEXT NOT NULL,
    "description" TEXT NOT NULL,
    "category" "CmdCategory" NOT NULL,

    PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "Command.name_unique" ON "Command"("name");

-- AddForeignKey
ALTER TABLE "Arg" ADD FOREIGN KEY ("commandId") REFERENCES "Command"("id") ON DELETE SET NULL ON UPDATE CASCADE;
