commands/telegram.js

const { InputFile, InlineKeyboard } = require('grammy');

const CLEAR_ERROR_MESSAGE_TIMEOUT = ++process.env.CLEAR_ERROR_MESSAGE_TIMEOUT || 10000;

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

/** 
 * @typedef {import('grammy').Context} Context
 */

/**
 * @typedef {object} TelegramInteraction
 * @property {'telegram'} 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 {number} 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 {'private' | 'group' | 'supergroup' | 'channel'} space.type Entity type
 * @property {number | string} space.id Entity id
 * @property {string?} space.title Chat title if entity type is not `private`
 * @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
 */

/**
 * Turns Telegram context to an object that can be used as an input to a common command handler
 * @param {Context} ctx
 * @param {number} limit number of parsable args
 * @return {Common.TelegramInteraction}
 * 
 * `limit` is tricky, it makes possible for argument to consist of multiple words
 * Example: `/foo bar baz bax`
 *   - if we set limit here to 1, we will limit the number 
 *     of args to 1 and this function will join all args with 
 *     spaces, therefore args = ['bar baz bax'].
 *   - if we set limit to 2, we will have 2 args as follows: 
 *     args = ['bar', 'baz bax'], and so on.
 *   - if we set limit to null, we will parse all words as standalone: 
 *     args = ['bar', 'baz', 'bax'].
 * 
 * @memberof Common.Telegram
 */
function commonizeContext(ctx, limit) {
    let args = [];

    // split all words by <space>
    args = ctx.message?.text?.replace(/ +/g, ' ')?.split(' ');

    // remove `/` from the name of the command
    if (args?.length) {
        args[0] = args[0].split('').slice(1).join('');
        // concat args to a single arg
        if (limit && (limit + 1) < args.length && limit > 0) {
            args[limit] = args.slice(limit).join(' ');
            args = args.slice(0, limit + 1);
        }
    }

    // Form interaction object
    let interaction = {
        platform: 'telegram',
        command_name: args?.[0],
    };

    if (args?.length > 1) {
        interaction.args = args.slice(1);
    }

    interaction.from = {
        id: ctx.from?.id,
        name: `${ctx.from?.first_name}${ctx.from?.last_name ? ` ${ctx.from.last_name}` : ''}`,
        username: ctx.from?.username
    }

    if (ctx.type === 'private') {
        interaction.space = interaction.from;
        interaction.space.type = 'private';
    }
    else {
        interaction.space = {
            id: ctx.chat?.id || ctx.callbackQuery?.chat_instance,
            type: ctx.chat?.type,
            title: ctx.chat?.title
        }
    }

    interaction.id = ctx.message?.message_id || ctx.callbackQuery?.inline_message_id;
    interaction.text = ctx.message?.text;
    interaction.data = ctx.callbackQuery?.data;
    interaction.ctx = ctx;
    
    return interaction;
}

/**
 * Get default response settings
 * @param {Context} ctx Telegram context
 * @param {object} overrides Overrides object
 * @returns {object} `other` object
 * @memberof Common.Telegram
 */
function getDefaultOther(ctx, overrides) {
    return {
        parse_mode: 'HTML',
        ...overrides,
        reply_parameters: {
            allow_sending_without_reply: true,
            message_id: ctx.message?.reply_to_message?.message_id || ctx.message?.message_id,
            ...overrides?.reply_parameters
        },
        link_preview_options: {
            is_disabled: true,
            ...overrides?.link_preview_options
        },
    }
}

/**
 * Transform response object by converting overrides to platform specific parameters
 * @param {object} response Response object
 * @returns {object} Updated response object
 * @memberof Common.Telegram
 */
function transformOverrides(response) {
    if (response.overrides?.followup && !response.overrides?.reply_markup) {
        const { text, url } = response.overrides.followup;
        response.overrides.reply_markup = new InlineKeyboard().url(text, url);
    }

    if (response.overrides?.buttons) {
        if (!response.overrides.reply_markup) response.overrides.reply_markup = new InlineKeyboard();
        response.overrides.reply_markup.row();
        for (const row of response.overrides.buttons) {
            for (const button of row) {
                if (button !== null) {
                    response.overrides.reply_markup.text(button.name, button.callback);
                }
            }
            response.overrides.reply_markup.row();
        }
    }
    return response;
}

/**
 * Reply to command with text message
 * @param {Context} ctx Telegram context
 * @param {object} response Response object
 * @param {import('../logger')} logger Logger 
 * @returns {Promise}
 * @memberof Common.Telegram
 */
async function replyWithText(ctx, response, logger) {
    logger.info(`Replying with text`, { response });

    return ctx.reply(
        response.text,
        getDefaultOther(ctx, response.overrides)
    ).then((message) => {
        logger.debug('Replied!', { message_id: message.message_id });
        if (CLEAR_ERROR_MESSAGE_TIMEOUT > 0 && response.type === 'error') {
            setTimeout(() => {
                if ((ctx.message.text || ctx.message.caption)?.split(' ') === 1) {
                    ctx.deleteMessage().catch(() => {});
                }
                ctx.api.deleteMessage(message.chat.id, message.message_id).catch(() => {});
            }, CLEAR_ERROR_MESSAGE_TIMEOUT)
        }
    }).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') {
            return replyWithText(
                ctx,
                {
                    type: 'error',
                    text: `Что-то случилось:\n<code>${err}</code>`
                },
                logger
            )
        }
    }).finally(() => {
        if (typeof response.callback === 'function') {
            response.callback();
        }
    });
}

/**
 * Command reply interface
 * @param {Context} ctx Telegram context
 * @param {object} response Response object
 * @param {import('../logger')} logger Logger
 * @returns {Promise}
 * @memberof Common.Telegram
 */
async function reply(ctx, response, logger) {
    const reply_methods = {
        'audio': ctx.replyWithAudio.bind(ctx),
        'animation': ctx.replyWithAnimation.bind(ctx),
        'chat_action': ctx.replyWithChatAction.bind(ctx),
        'contact': ctx.replyWithContact.bind(ctx),
        'dice': ctx.replyWithDice.bind(ctx),
        'document': ctx.replyWithDocument.bind(ctx),
        'game': ctx.replyWithGame.bind(ctx),
        'invoice': ctx.replyWithInvoice.bind(ctx),
        'location': ctx.replyWithLocation.bind(ctx),
        'media_group': ctx.replyWithMediaGroup.bind(ctx),
        'photo': ctx.replyWithPhoto.bind(ctx),
        'poll': ctx.replyWithPoll.bind(ctx),
        'sticker': ctx.replyWithSticker.bind(ctx),
        'venue': ctx.replyWithVenue.bind(ctx),
        'video': ctx.replyWithVideo.bind(ctx),
        'video_note': ctx.replyWithVideoNote.bind(ctx),
        'voice': ctx.replyWithVoice.bind(ctx),
    };

    const sendReply = reply_methods[response.type];

    if (!sendReply) {
        return replyWithText(ctx, response, logger);
    }

    let media;

    if (response.filename) {
        logger.info(`Replying with file of type: ${response.type}`);
        media = new InputFile(response.media, response.filename);
    }
    else {
        logger.info(`Replying with media of type: ${response.type}`);
        media = response.media;
    }

    return sendReply(
        media,
        {
            caption: response.text,
            ...getDefaultOther(ctx, response.overrides)
        }
    ).then((message) => {
        logger.debug('Replied!', { message_id: message.message_id});
    }).catch(err => {
        logger.error(`Error while replying`, { error: err.stack || err});
        return replyWithText(
            ctx,
            {
                type: 'error',
                text: `Что-то случилось:\n<code>${err}</code>`
            },
            logger
        )
    }).finally(() => {
        if (typeof response.callback === 'function') {
            response.callback();
        }
    });
}

/**
 * Command handler interface
 * @param {Context} ctx Telegram context
 * @param {Common.CommandHandler} handler Handler function for command
 * @param {Common.CommandDefinition} definition Command definition
 * @memberof Common.Telegram
 */
function handleCommand(ctx, handler, definition) {
    const common_interaction = commonizeContext(ctx, definition?.limit);
    const log_meta = {
        module: 'telegram-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}`);
    handler(common_interaction)
    .then(transformOverrides)
    .then(response => {
        return reply(ctx, response, logger);
    }).catch((err) => {
        logger.error(`Error while handling`, { error: err.stack || err });
        replyWithText(
            ctx,
            {
                type: 'error',
                text: `Что-то случилось:\n<code>${err}</code>`
            },
            logger
        )
    });
}

/**
 * Command handler interface that gets a response array acceptable by {@link Telegram.Interaction}
 * @param {Context} ctx Telegram context
 * @param {Common.CommandHandler} handler Handler function for command
 * @param {Common.CommandDefinition} definition Command definition
 * @returns {object[]} 
 * @memberof Common.Telegram
 */
async function getLegacyResponse(ctx, handler, definition) {
    const common_interaction = commonizeContext(ctx, definition?.limit);
    const log_meta = {
        module: 'telegram-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}`);
    let response = await handler(common_interaction).then(transformOverrides);

    return [
        response.type === 'error' ? response.text : null,
        response.type === 'text' ? response.text : response,
        null,
        response.overrides
    ];
}

/**
 * Process answer callback
 * @param {Context} ctx Telegram context
 * @param {object} response Response object
 * @return {Promise}
 * @memberof Common.Telegram
 */
async function answerCallback(ctx, response) {
    if (response.type === 'error' || response.type === 'delete_buttons') {
        return ctx.answerCallbackQuery({
            text: response.text
        }).then(() => ctx.editMessageReplyMarkup({
            reply_markup: null
        }));
    }

    ctx.answerCallbackQuery();

    switch(response.type) {
        case 'edit_text':
            return ctx.editMessageText(response.text, getDefaultOther(ctx, response.overrides));
        case 'edit_media':
            let media;
            if (response.filename) {
                media = new InputFile(response.media, response.filename);
            }
            else {
                media = response.media;
            }
            return ctx.editMessageMedia(media, getDefaultOther(ctx, response.overrides));
        case 'edit_caption':
            return ctx.editMessageCaption({ caption: response.text, ...getDefaultOther(ctx, response.overrides) });
        case 'edit_buttons':
            return ctx.editMessageReplyMarkup(getDefaultOther(ctx, response.overrides));
    }
}

/**
 * Callback handler interface
 * @async
 * @param {Context} ctx Telegram context
 * @param {Common.CommandHandler} handler Handler function for callback
 * @memberof Common.Telegram
 */
async function handleCallback(ctx, handler) {
    const common_interaction = commonizeContext(ctx);
    const log_meta = {
        module: 'telegram-common-callback-handler',
        callback_data: common_interaction.data,
        platform: common_interaction.platform,
        interaction: common_interaction
    }
    const logger = require('../logger').child(log_meta);
    common_interaction.logger = logger.child({ ...log_meta, module: `common-callback-${common_interaction.command_name}` });

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

    handler(common_interaction)
    .then(transformOverrides)
    .then(response => answerCallback(ctx, response))
    .catch(err => {
        logger.error('Failed to answer callback', { error: err.stack || err });
        ctx.answerCallbackQuery({ text: 'Что-то сломалось' }).catch(() => {});
    })
}

module.exports = {
    commonizeContext,
    handleCommand,
    getLegacyResponse,
    handleCallback,
}