Discord-InterChat/InterChat

View on GitHub
src/utils/Utils.ts

Summary

Maintainability
A
0 mins
Test Coverage
import type { RemoveMethods, ThreadParentChannel } from '#types/CustomClientProps.d.ts';
import Constants from '#utils/Constants.js';
import { ErrorEmbed } from '#utils/EmbedUtils.js';
import Logger from '#utils/Logger.js';
import { captureException } from '@sentry/node';
import type { ClusterManager } from 'discord-hybrid-sharding';
import {
  EmbedBuilder,
  Message,
  type ColorResolvable,
  type CommandInteraction,
  type GuildTextBasedChannel,
  type Interaction,
  type MessageComponentInteraction,
  type RepliableInteraction,
  type Snowflake,
  type VoiceBasedChannel,
} from 'discord.js';
import startCase from 'lodash/startCase.js';
import toLower from 'lodash/toLower.js';

export const resolveEval = <T>(value: T[]) =>
  value?.find((res) => Boolean(res)) as RemoveMethods<T> | undefined;

export const msToReadable = (milliseconds: number, short = true): string => {
  if (milliseconds < 0) return 'Invalid input';
  if (milliseconds === 0) return short ? '0s' : '0 seconds';

  const timeUnits = [
    { div: 31536000000, short: 'y', long: 'year' },
    { div: 2629746000, short: 'm', long: 'month' },
    { div: 86400000, short: 'd', long: 'day' },
    { div: 3600000, short: 'h', long: 'hour' },
    { div: 60000, short: 'm', long: 'minute' },
    { div: 1000, short: 's', long: 'second' },
  ];

  let remainingMs = milliseconds;
  const parts: string[] = [];

  for (const unit of timeUnits) {
    const value = Math.floor(remainingMs / unit.div);
    if (value > 0) {
      // eslint-disable-next-line no-nested-ternary
      const suffix = short ? unit.short : value === 1 ? ` ${unit.long}` : ` ${unit.long}s`;

      parts.push(`${value}${suffix}`);
      remainingMs %= unit.div;
    }
  }

  // Limit to two most significant parts for readability
  return parts.join(' ');
};

export const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export const yesOrNoEmoji = (option: unknown, yesEmoji: string, noEmoji: string) =>
  option ? yesEmoji : noEmoji;

export const disableComponents = (message: Message) =>
  message.components.flatMap((row) => {
    const jsonRow = row.toJSON();
    jsonRow.components.forEach((component) => (component.disabled = true));
    return jsonRow;
  });

const findExistingWebhook = async (channel: ThreadParentChannel | VoiceBasedChannel) => {
  const webhooks = await channel?.fetchWebhooks().catch(() => null);
  return webhooks?.find((w) => w.owner?.id === channel.client.user?.id);
};

const createWebhook = async (
  channel: ThreadParentChannel | VoiceBasedChannel,
  avatar: string,
  name: string,
) =>
  await channel
    ?.createWebhook({
      name,
      avatar,
    })
    .catch(() => undefined);

export const getOrCreateWebhook = async (
  channel: GuildTextBasedChannel,
  avatar = Constants.Links.EasterAvatar,
  name = 'InterChat Network',
) => {
  const channelOrParent = channel.isThread() ? channel.parent : channel;
  if (!channelOrParent) return null;

  const existingWebhook = await findExistingWebhook(channelOrParent);
  return existingWebhook || (await createWebhook(channelOrParent, avatar, name));
};

export const getCredits = () => [
  ...Constants.DeveloperIds,
  ...Constants.StaffIds,
  ...Constants.SupporterIds,
];

export const checkIfStaff = (userId: string, onlyCheckForDev = false) => {
  const staffMembers = [...Constants.DeveloperIds, ...(onlyCheckForDev ? [] : Constants.StaffIds)];
  return staffMembers.includes(userId);
};

export const replaceLinks = (string: string, replaceText = '`[LINK HIDDEN]`') =>
  string.replaceAll(Constants.Regex.Links, replaceText);

export const calculateRating = (ratings: number[]): number => {
  if (ratings.length === 0) return 0;

  const sum = ratings.reduce((acc, cur) => acc + cur, 0);
  const average = sum / ratings.length;
  return Math.round(average * 10) / 10;
};

export const toTitleCase = (str: string) => startCase(toLower(str));

export const getReplyMethod = (
  interaction: RepliableInteraction | CommandInteraction | MessageComponentInteraction,
) => (interaction.replied || interaction.deferred ? 'followUp' : 'reply');

/**
    Invoke this method to handle errors that occur during command execution.
    It will send an error message to the user and log the error to the system.
  */
export const sendErrorEmbed = async (
  repliable: RepliableInteraction | Message,
  errorCode: string,
) => {
  const errorEmbed = new ErrorEmbed({ errorCode });
  if (repliable instanceof Message) {
    return await repliable.reply({
      embeds: [errorEmbed],
      allowedMentions: { repliedUser: false },
    });
  }

  const method = getReplyMethod(repliable);
  return await repliable[method]({
    embeds: [errorEmbed],
    ephemeral: true,
  });
};

export const handleError = (e: Error, repliable?: Interaction | Message) => {
  // log the error to the system
  Logger.error(e);

  let extra = {};

  if (repliable instanceof Message) {
    extra = { user: { id: repliable.author.id, username: repliable.author.username } };
  }
  else if (repliable) {
    const identifier =
      repliable.isCommand() || repliable.isAutocomplete()
        ? repliable.commandName
        : repliable.customId;

    extra = {
      user: { id: repliable.user.id, username: repliable.user.username },
      extra: { type: repliable.type, identifier },
    };
  }

  // capture the error to Sentry.io with additional information
  const errorCode = captureException(e, extra);

  // reply with an error message to the user
  if (repliable && 'reply' in repliable) sendErrorEmbed(repliable, errorCode).catch(Logger.error);
};

export const isDev = (userId: Snowflake) => Constants.DeveloperIds.includes(userId);

export const escapeRegexChars = (input: string, type: 'simple' | 'full' = 'simple'): string =>
  input.replace(
    type === 'simple' ? Constants.Regex.SimpleRegexEscape : Constants.Regex.RegexChars,
    '\\$&',
  );

export const parseEmoji = (emoji: string) => {
  const match = emoji.match(Constants.Regex.Emoji);
  if (!match) return null;

  const [, animated, name, id] = match;
  return { animated: Boolean(animated), name, id };
};

export const getEmojiId = (emoji: string | undefined) => {
  const res = parseEmoji(emoji || '');
  return res?.id ?? emoji;
};

export const getOrdinalSuffix = (num: number) => {
  const j = num % 10;
  const k = num % 100;

  if (j === 1 && k !== 11) return 'st';
  else if (j === 2 && k !== 12) return 'nd';
  else if (j === 3 && k !== 13) return 'rd';
  return 'th';
};

export const getUsername = async (client: ClusterManager, userId: Snowflake) => {
  if (!client) return null;

  const username = resolveEval(
    await client.broadcastEval(
      async (c, ctx) => {
        const user = await c.users.fetch(ctx.userId).catch(() => null);
        return user?.username;
      },
      { context: { userId } },
    ),
  );

  return username;
};

export const containsInviteLinks = (str: string) => {
  const inviteLinks = ['discord.gg', 'discord.com/invite', 'dsc.gg'];
  return inviteLinks.some((link) => str.includes(link));
};

export const getTagOrUsername = (username: string, discrim: string) =>
  discrim !== '0' ? `${username}#${discrim}` : username;

export const isHumanMessage = (message: Message) =>
  !message.author.bot && !message.system && !message.webhookId;

export const trimAndCensorBannedWebhookWords = (content: string) =>
  content.slice(0, 35).replace(Constants.Regex.BannedWebhookWords, '[censored]');

export const simpleEmbed = (
  description: string,
  opts?: { color?: ColorResolvable; title?: string },
) =>
  new EmbedBuilder()
    .setTitle(opts?.title ?? null)
    .setColor(opts?.color ?? Constants.Colors.invisible)
    .setDescription(description.toString());

export const getStars = (rating: number, emoji = '⭐') => {
  const stars = Math.round(rating);
  return emoji.repeat(stars);
};