Discord-InterChat/InterChat

View on GitHub
src/commands/context-menu/editMsg.ts

Summary

Maintainability
A
0 mins
Test Coverage
/* eslint-disable complexity */
import BaseCommand from '#main/core/BaseCommand.js';
import { RegisterInteractionHandler } from '#main/decorators/RegisterInteractionHandler.js';
import HubSettingsManager from '#main/managers/HubSettingsManager.js';
import { SerializedHubSettings } from '#main/modules/BitFields.js';
import VoteBasedLimiter from '#main/modules/VoteBasedLimiter.js';
import { HubService } from '#main/services/HubService.js';
import {
  findOriginalMessage,
  getBroadcast,
  getBroadcasts,
  getOriginalMessage,
} from '#main/utils/network/messageUtils.js';
import Constants, { ConnectionMode, emojis } from '#utils/Constants.js';
import { CustomID } from '#utils/CustomID.js';
import db from '#utils/Db.js';
import { getAttachmentURL } from '#utils/ImageUtils.js';
import { t } from '#utils/Locale.js';
import { censor } from '#utils/ProfanityUtils.js';
import { containsInviteLinks, handleError, replaceLinks } from '#utils/Utils.js';
import {
  ActionRowBuilder,
  ApplicationCommandType,
  EmbedBuilder,
  Message,
  MessageContextMenuCommandInteraction,
  ModalBuilder,
  ModalSubmitInteraction,
  RESTPostAPIApplicationCommandsJSONBody,
  TextInputBuilder,
  TextInputStyle,
  User,
  userMention,
} from 'discord.js';

interface ImageUrls {
  oldURL?: string | null;
  newURL?: string | null;
}

export default class EditMessage extends BaseCommand {
  readonly data: RESTPostAPIApplicationCommandsJSONBody = {
    type: ApplicationCommandType.Message,
    name: 'Edit Message',
    dm_permission: false,
  };

  readonly cooldown = 10_000;

  async execute(interaction: MessageContextMenuCommandInteraction): Promise<void> {
    const isOnCooldown = await this.checkOrSetCooldown(interaction);
    if (isOnCooldown) return;

    const { userManager } = interaction.client;
    const target = interaction.targetMessage;
    const locale = await userManager.getUserLocale(interaction.user.id);
    const voteLimiter = new VoteBasedLimiter('editMsg', interaction.user.id, userManager);

    if (await voteLimiter.hasExceededLimit()) {
      await interaction.reply({
        content: `${emojis.topggSparkles} You've hit your daily limit for message edits. [Vote for InterChat](${Constants.Links.Vote}) on top.gg to get unlimited edits!`,
      });
      return;
    }

    const messageInDb =
      (await getOriginalMessage(interaction.targetId)) ??
      (await findOriginalMessage(interaction.targetId));

    if (!messageInDb) {
      await interaction.reply({
        content: t('errors.unknownNetworkMessage', locale, { emoji: emojis.no }),
      });
      return;
    }
    else if (interaction.user.id !== messageInDb.authorId) {
      await interaction.reply({
        content: t('errors.notMessageAuthor', locale, { emoji: emojis.no }),
      });
      return;
    }

    const modal = new ModalBuilder()
      .setCustomId(new CustomID().setIdentifier('editMsg').addArgs(target.id).toString())
      .setTitle('Edit Message')
      .addComponents(
        new ActionRowBuilder<TextInputBuilder>().addComponents(
          new TextInputBuilder()
            .setRequired(true)
            .setCustomId('newMessage')
            .setStyle(TextInputStyle.Paragraph)
            .setLabel('Please enter your new message.')
            .setValue(
              `${target.content ?? target.embeds[0]?.description ?? ''}\n${
                target.embeds[0]?.image?.url ?? ''
              }`,
            )
            .setMaxLength(950),
        ),
      );

    await interaction.showModal(modal);
  }

  @RegisterInteractionHandler('editMsg')
  override async handleModals(interaction: ModalSubmitInteraction): Promise<void> {
    // Defer the reply to give the user feedback
    await interaction.deferReply({ ephemeral: true });

    // Parse the custom ID to get the message ID
    const customId = CustomID.parseCustomId(interaction.customId);
    const [messageId] = customId.args;

    // Fetch the original message
    const target = await interaction.channel?.messages.fetch(messageId).catch(() => null);
    if (!target) {
      await this.replyEmbed(interaction, 'errors.unknownNetworkMessage', {
        t: { emoji: emojis.no },
      });
      return;
    }

    // Get the original message data
    const originalMsgData =
      (await getOriginalMessage(target.id)) ?? (await findOriginalMessage(target.id));

    if (!originalMsgData?.hubId) {
      await this.replyEmbed(interaction, 'errors.unknownNetworkMessage', {
        t: { emoji: emojis.no },
      });
      return;
    }

    // Fetch the hub information
    const hubService = new HubService(db);
    const hub = await hubService.fetchHub(originalMsgData.hubId);
    if (!hub) {
      await interaction.editReply(
        t('errors.unknownNetworkMessage', await this.getLocale(interaction), {
          emoji: emojis.no,
        }),
      );
      return;
    }

    // Get the new message input from the user
    const userInput = interaction.fields.getTextInputValue('newMessage');
    const settingsManager = new HubSettingsManager(originalMsgData.hubId, hub.settings);
    const messageToEdit = this.sanitizeMessage(userInput, settingsManager.getAllSettings());

    // Check if the message contains invite links
    if (settingsManager.getSetting('BlockInvites') && containsInviteLinks(messageToEdit)) {
      await interaction.editReply(
        t('errors.inviteLinks', await this.getLocale(interaction), { emoji: emojis.no }),
      );
      return;
    }

    const mode =
      target.id === originalMsgData.messageId
        ? ConnectionMode.Compact
        : ((
          await getBroadcast(originalMsgData?.messageId, originalMsgData?.hubId, {
            channelId: target.channelId,
          })
        )?.mode ?? ConnectionMode.Compact);

    // Prepare the new message contents and embeds
    const imageURLs = await this.getImageURLs(target, mode, messageToEdit);
    const newContents = this.getCompactContents(messageToEdit, imageURLs);
    const newEmbeds = await this.buildEmbeds(target, mode, messageToEdit, {
      guildId: originalMsgData.guildId,
      user: interaction.user,
      imageURLs,
    });

    // Find all the messages that need to be edited
    const broadcastedMsgs = Object.values(await getBroadcasts(target.id, originalMsgData.hubId));
    const channelSettingsArr = await db.connectedList.findMany({
      where: { channelId: { in: broadcastedMsgs.map((c) => c.channelId) } },
    });

    let counter = 0;
    for (const msg of broadcastedMsgs) {
      const connection = channelSettingsArr.find((c) => c.channelId === msg.channelId);
      if (!connection) continue;

      const webhook = await interaction.client
        .fetchWebhook(connection.webhookURL.split('/')[connection.webhookURL.split('/').length - 2])
        .catch(() => null);

      if (webhook?.owner?.id !== interaction.client.user.id) continue;

      let content;
      let embeds;
      if (msg.mode === ConnectionMode.Embed) {
        embeds = connection.profFilter ? [newEmbeds.censored] : [newEmbeds.normal];
      }
      else {
        content = connection.profFilter ? newContents.censored : newContents.normal;
      }

      // Edit the message
      const edited = await webhook
        .editMessage(msg.messageId, {
          content,
          embeds,
          threadId: connection.parentId ? connection.channelId : undefined,
        })
        .catch(() => null);

      if (edited) counter++;
    }

    // Update the reply with the edit results
    await interaction
      .editReply(
        t('network.editSuccess', await this.getLocale(interaction), {
          edited: counter.toString(),
          total: broadcastedMsgs.length.toString(),
          emoji: emojis.yes,
          user: userMention(originalMsgData.authorId),
        }),
      )
      .catch(handleError);

    // Decrement the vote limiter
    const voteLimiter = new VoteBasedLimiter(
      'editMsg',
      interaction.user.id,
      interaction.client.userManager,
    );
    await voteLimiter.decrementUses();
  }

  private async getImageURLs(
    target: Message,
    mode: ConnectionMode,
    newMessage: string,
  ): Promise<ImageUrls> {
    const oldURL =
      mode === ConnectionMode.Compact
        ? await getAttachmentURL(target.content)
        : target.embeds[0]?.image?.url;

    const newURL = await getAttachmentURL(newMessage);

    return { oldURL, newURL };
  }

  private async buildEmbeds(
    target: Message,
    mode: ConnectionMode,
    messageToEdit: string,
    opts: { user: User; guildId: string; imageURLs?: ImageUrls },
  ) {
    let embedContent = messageToEdit;
    let embedImage = null;

    // This if check must come on top of the next one at all times
    // because we want newImage Url to be given priority for the embedImage
    if (opts.imageURLs?.newURL) {
      embedContent = embedContent.replace(opts.imageURLs.newURL, '');
      embedImage = opts.imageURLs.newURL;
    }
    if (opts.imageURLs?.oldURL) {
      embedContent = embedContent.replace(opts.imageURLs.oldURL, '');
      embedImage = opts.imageURLs.oldURL;
    }

    let embed: EmbedBuilder;

    if (mode === ConnectionMode.Embed) {
      // utilize the embed directly from the message
      embed = EmbedBuilder.from(target.embeds[0]).setDescription(embedContent).setImage(embedImage);
    }
    else {
      const guild = await target.client.fetchGuild(opts.guildId);

      // create a new embed if the message being edited is in compact mode
      embed = new EmbedBuilder()
        .setAuthor({ name: opts.user.username, iconURL: opts.user.displayAvatarURL() })
        .setDescription(embedContent)
        .setColor(Constants.Colors.invisible)
        .setImage(embedImage)
        .addFields(
          target.embeds.at(0)?.fields.at(0)
            ? [{ name: 'Replying-to', value: `${target.embeds[0].description}` }]
            : [],
        )
        .setFooter({ text: `Server: ${guild?.name}` });
    }

    const censored = EmbedBuilder.from({ ...embed.data, description: censor(embedContent) });

    return { normal: embed, censored };
  }

  private sanitizeMessage(content: string, settings: SerializedHubSettings) {
    const newMessage = settings.HideLinks ? replaceLinks(content) : content;
    return newMessage;
  }

  private getCompactContents(messageToEdit: string, imageUrls: ImageUrls) {
    let compactMsg = messageToEdit;

    if (imageUrls.oldURL && imageUrls.newURL) {
      // use the new url instead
      compactMsg = compactMsg.replace(imageUrls.oldURL, imageUrls.newURL);
    }

    return { normal: compactMsg, censored: censor(compactMsg) };
  }
}