commands/discord.js

const TurndownService = require('turndown');
const { EmbedBuilder, ButtonBuilder, ActionRowBuilder, ButtonStyle, MessagePayload } = require('discord.js');

/**
 * Discord Common Interface Implementation
 * @namespace Discord
 * @memberof Common
 */

/** @ignore */
const turndownService = new TurndownService({
    codeBlockStyle: 'fenced',
    br: ' '
});

turndownService.addRule('a', {
    filter: 'a',
    replacement: (content, node) => {
        const href = node.getAttribute('href');
        return `[${content}](<${href}>)`;
    }
});

/** 
 * @typedef {import('discord.js').Interaction} Interaction 
 */

/**
 * @typedef {object} DiscordInteraction
 * @property {'discord'} platform Interaction source platform
 * @property {string?} command_name Command name
 * @property {string} text Command input as one line
 * @property {string[]?} args Array of command args
 * @property {object} from Sender info
 * @property {string?} from.id Sender id
 * @property {string?} from.username Sender username
 * @property {string?} from.name Sender name
 * @property {object} space Info about the entity where command was triggered
 * @property {'guild' | 'private'} space.type Type of an entity
 * @property {string?} space.id Entity id
 * @property {string?} space.title Server name if entity is `guild`
 * @property {string?} space.username Sender username if entity is `private`
 * @property {string?} space.name Sender name if entity is `private`
 * @property {string} id Interaction id
 * @property {string?} data Callback query data
 * @memberof Common
 */

/**
 * 
 * @param {Interaction} interaction 
 * @param {Common.CommandDefinition} definition 
 * @returns {Common.DiscordInteraction}
 * @memberof Common.Discord
 */
function commonizeInteraction(interaction, definition) {
    let common_interaction = {
        platform: 'discord',
        command_name: interaction.commandName,
        text: interaction.toString(),
    }

    // Parse args
    if (definition?.args) {
        common_interaction.args = [];
        definition.args.forEach(arg => {
            common_interaction.args.push(interaction.options.get(arg.name)?.value);
        });
    }

    if (interaction.user) {
        common_interaction.from = {
            id: interaction.user.id,
            username: interaction.user.username
        }
    }

    if (interaction.guild) {
        common_interaction.space = {
            id: interaction.guild.id,
            title: interaction.guild.name,
            type: 'guild'
        }
        common_interaction.from.name = interaction.member.displayName;
    }
    else {
        common_interaction.space = common_interaction.from;
        common_interaction.type = 'private';
    }

    common_interaction.id = interaction.id;
    common_interaction.data = interaction.customId;

    return common_interaction;
}

/**
 * Transform response object by converting overrides to platfrom specific parameters
 * @param {object} response 
 * @returns {object} Updated response object
 * @memberof Common.Discord
 */
function transformOverrides(response) {
    if (response?.overrides?.followup) {
        const { text, url } = response.overrides.followup;
        const components = [
            new ActionRowBuilder()
                .addComponents(
                    new ButtonBuilder()
                        .setLabel(text)
                        .setURL(url)
                        .setStyle(ButtonStyle.Link)
                )
        ];
        response.components = components;
    }

    if (response.overrides?.buttons) {
        if (!response.components) response.components = [];
        
        response.components.push(...response.overrides.buttons.map(row => {
            const actionRow = new ActionRowBuilder();
            actionRow.addComponents(...row.map(button => {
                return new ButtonBuilder()
                    .setLabel(button.name)
                    .setCustomId(button.callback)
                    .setStyle(ButtonStyle.Primary);
            }));
            return actionRow;
        }));
    }

    if (response?.overrides?.embeded_image) {
        response.embeds = [new EmbedBuilder().setImage(response?.overrides?.embeded_image)];
    }

    return response;
}

/**
 * Reply to command with text message
 * @param {Interaction} interaction Discord context
 * @param {object} response Response object
 * @param {import('../logger')} logger Logger
 * @memberof Common.Discord
 */
function replyWithText(interaction, response, logger) {
    logger.info(`Replying with text`, { response });

    // if (response?.overrides?.followup) {
    //     const { text, url } = response.overrides.followup;
    //     let embed = new EmbedBuilder;
    //     text && embed.setTitle(text);
    //     url && embed.setURL(url);
    //     response.embeds = [embed];
    // }

    interaction.editReply({ content: response.text, components: response?.components, embeds: response?.embeds })
    .then((messsage) => {
        logger.debug('Replied!', { message_id: messsage.id });
    }).catch(err => {
        logger.error(`Error while replying`, { error: err.stack || err });
        // Try again if only it wasn't an error message
        if (response.type !== 'error') {
            replyWithText(
                interaction,
                {
                    type: 'error',
                    text: `Что-то случилось:\n\`${err}\``
                },
                logger
            );
        }
    });
}

/**
 * Reply to command with embeded component
 * @param {Interaction} interaction Discord context
 * @param {object} response Response object
 * @param {import('../logger')} logger Logger
 * @memberof Common.Discord
 */
function replyWithEmbed(interaction, response, logger) {
    const payload = { embeds: [] };

    const embed = new EmbedBuilder();

    if (response.text) {
        payload.content = response.text;
    }

    if (response.components) {
        payload.components = response.components;
    }

    if (response.filename) {
        logger.info(`Replying with file of type: ${response.type}`);
        payload.files = [{ name: response.filename, attachment: response.media }];
    }
    else {
        logger.info(`Replying with media of type: ${response.type}`);
        embed.setImage(response.media);
        payload.embeds.push(embed);
    }
    if (response?.embeds) {
        payload.embeds.unshift(...response.embeds);
    }

    interaction.editReply(payload)
    .then((messsage) => {
        logger.debug('Replied!', { message_id: messsage.id });
    }).catch(err => {
        logger.error(`Error while replying`, { error: err.stack || err });
        replyWithText(
            interaction,
            {
                type: 'error',
                text: `Что-то случилось:\n\`${err}\``
            },
            logger
        );
    });
}

/**
 * Reply to command with file
 * @param {Interaction} interaction Discord context
 * @param {object} response Response object
 * @param {import('../logger')} logger Logger
 * @memberof Common.Discord
 */
function replyWithFile(interaction, response, logger) {
    const payload = { embeds: [] };

    if (response.text) {
        payload.content = response.text;
    }

    if (response.components) {
        payload.components = response.components;
    }

    if (response.filename) {
        logger.info(`Replying with file of type: ${response.type}`);
        payload.files = [{ name: response.filename, attachment: response.media }];
    }
    if (response?.embeds) {
        payload.embeds.unshift(...response.embeds);
    }

    interaction.editReply(payload)
    .then((messsage) => {
        logger.debug('Replied!', { message_id: messsage.id });
    }).catch(err => {
        logger.error(`Error while replying`, { error: err.stack || err });
        replyWithText(
            interaction,
            {
                type: 'error',
                text: `Что-то случилось:\n\`${err}\``
            },
            logger
        );
    });
}

/**
 * Command reply interface
 * @param {Interaction} interaction Discord context
 * @param {object} response Response object
 * @param {import('../logger')} logger Logger
 * @memberof Common.Discord
 */
function reply(interaction, response, logger) {
    while (response?.text?.length >= 2000) response.text = response.text.split('\n').slice(0, -1).join('\n');

    if (['text', 'error'].includes(response.type)) {
        replyWithText(interaction, response, logger);
        return;
    }

    if (['photo', 'image'].includes(response.type)) {
        replyWithEmbed(interaction, response, logger);
        return;
    }

    if (response.type === 'document' && response.filename) {
        replyWithFile(interaction, response, logger);
        return;
    }

    logger.error(`Can't send file of type: ${response.type}`);

    if (response.text) {
        logger.info('Sending text instead');
        replyWithText(
            interaction,
            {
                type: 'text',
                text: response.text
            },
            logger
        );
        return;
    }
    
    replyWithText(
        interaction,
        {
            type: 'error',
            text: `Пока что не могу ответить на это сообщение из-за типа контента в ответе: \`${response.type}\``
        },
        logger
    );
}


/**
 * Command handler interface
 * @param {Interaction} interaction Discord context
 * @param {Common.CommandHandler} handler Handler function for command
 * @param {Common.CommandDefinition} definition Command definition
 * @memberof Common.Discord
 */
function handleCommand(interaction, handler, definition) {
    const common_interaction = commonizeInteraction(interaction, definition);
    const log_meta = {
        module: 'discord-common-command-handler',
        command_name: common_interaction.command_name,
        platform: common_interaction.platform,
        interaction: common_interaction
    };
    const logger = require('../logger').child(log_meta);
    common_interaction.logger = logger.child({ ...log_meta, module: `common-command-${common_interaction.command_name}` });

    logger.info(`Received command: ${common_interaction.text}`);

    interaction.deferReply()
    .then(() => handler(common_interaction))
    .then(transformOverrides)
    .then(response => {
        if (response.text) {
            response.text = response.text.replace(/\n|\\n/gm, '<br/>');
            response.text = turndownService.turndown(response.text);
            response.text = response.text.replace(/( *\n *){2,}/gm, '\n\n')
        }
        reply(interaction, response, logger);
    }).catch(err => {
        logger.error(`Error while handling`, { error: err.stack || err });
        replyWithText(
            interaction,
            {
                type: 'error',
                text: `Что-то случилось:\n\`${err}\``
            },
            logger
        );
    });
};

/**
 * Process answer callback
 * @param {Interaction} interaction Discord context
 * @param {object} response Response object
 * @returns {Promise}
 * @memberof Common.Discord
 */
async function answerCallback(interaction, response) {
    if (response.type === 'error') {
        return interaction.followUp(response.text);
    }

    switch(response.type) {
        case 'edit_text':
        case 'edit_caption':
            return interaction.editReply({
                content: response.text,
                components: response.components,
                embeds: response.embeds
            });
        case 'edit_media':
            if (response.filename) {
                response.files = [{ name: response.filename, attachment: response.media }];
            }
            else {
                if (!response.embdes) response.embeds = [];
                response.embeds.push(new EmbedBuilder().setImage(response.media));
            }
            return interaction.editReply({
                embeds: response.embeds,
                components: response.components,
                files: response.files,
            })
        case 'delete_buttons':
            return interaction.followUp(response.text)
                .then(() => interaction.editReply({
                    components: []
                }));
        case 'edit_buttons':
            return interaction.editReply({
                components: response.components
            });
    }
}

/**
 * Callback handler interface
 * @async
 * @param {Interaction} interaction Discord context
 * @param {Common.CommandHandler} handle Handler function for callback
 * @memberof Common.Discord
 */
async function handleCallback(interaction, handle) {
    const common_interaction = commonizeInteraction(interaction);
    const log_meta = {
        module: 'discord-common-interface-handler',
        command_name: common_interaction.command_name,
        platform: common_interaction.platform,
        interaction: common_interaction
    };
    const logger = require('../logger').child(log_meta);
    common_interaction.logger = logger.child({ ...log_meta, module: `common-handler-${common_interaction.command_name}` });

    logger.info(`Received callback: ${common_interaction.data}`);

    interaction.deferUpdate()
    .then(() => handle(common_interaction))
    .then(transformOverrides)
    .then(response => {
        if (response.text) {
            response.text = response.text.replace(/\n|\\n/gm, '<br/>');
            response.text = turndownService.turndown(response.text);
            response.text = response.text.replace(/( *\n *){2,}/gm, '\n\n')
        }
        return answerCallback(interaction, response);
    })
}

module.exports = {
    commonizeInteraction,
    handleCommand,
    handleCallback,
};