commands/handlers/game-handler.js

const hltb = new (require('howlongtobeat').HowLongToBeatService)();

const { RAWG_API_BASE, RAWG_BASE } = require('../../config.json');
const { genKey, range, encodeCallbackData, listingMenuCallback } = require('../utils');
const { getRedis } = require('../../services/redis');
const logger = require('../../logger').child({ module: 'game-handler' });
const { wideSpace } = require('../../utils');

/**
 * Game Command
 * @namespace game
 * @memberof Commands
 */

/**
 * Get search results
 * @param {{search: string, ...args}} - Search parameters 
 * @returns {object[]}
 * @memberof Commands.game
 */
const searchRAWG = async ({ search, ...args } = {}) => {
    return await fetch(
        `${RAWG_API_BASE}/games?`
        + new URLSearchParams({
            key: process.env.RAWG_TOKEN,
            search,
            page_size: 10,
            ...args
        }));
}

/**
 * Get game from RAWG
 * @param {{ slug: string }}  
 * @returns 
 */
const getGameFromRAWG = async ({ slug }) => {
    return await fetch(
        `${RAWG_API_BASE}/games/${slug}?`
        + new URLSearchParams({
            key: process.env.RAWG_TOKEN,
        }))
        .then(res => res.ok ? res.json() : null)
        .catch(() => null);
}

/**
 * Get HLTB info
 * @param {{ name: string, year: number }} 
 * @returns {Promise<import('howlongtobeat').HowLongToBeatEntry?>}
 * @memberof Commands.game
 */
const getHltbInfo = async ({ name, year } = {}) => {
    return await hltb.searchWithOptions(name, { year })
        .then(result => result.length > 0 ? result[0] : null)
        .catch(err => {
            logger.error(`Failed HLTB search for [${name}] [${year}]`, { error: err.stack || err });
            return null;
        });
};

/**
 * Transform game details to text
 * @param {{slug: string, name: string, released: string?, metacritic: number?, platforms: {name: string}[]?, stores: {name: string}[]?, hltb?: {url: string, playtimes: {name: string, value: string | number}[]}?}} game Game details
 * @returns {string}
 * @memberof Commands.game
 */
const getTextFromGameDetail = (game) => {
    return `🎮 <a href="${RAWG_BASE}/games/${game?.slug}">${game.name}</a>\n`
        + (game.released ? `Дата релиза: ${(new Date(game.released)).toLocaleDateString('de-DE')}\n` : '')
        + (game.metacritic ? `Metacritic: ${game.metacritic}\n` : '')
        + (game.platforms?.length ? `Платформы: ${game.platforms.filter(v => v.platform?.name).map(v => v?.platform.name).join(', ')}\n` : '')
        + (game.stores?.length ? `Магазины: ${game.stores.filter(v => v?.store?.name).map(v => v.store.name).join(', ')}\n` : '')
        + (game.hltb?.playtimes?.length ? `<a href="${game.hltb?.url}">HLTB</a>:\n${game.hltb.playtimes.map(({ name, value }) => `${wideSpace}${name}: ${value}`).join('\n')}` : '');
}

/**
 * Save RAWG.io results in redis for quick access
 * @param {string} key Redis key
 * @param {object[]} games Game details
 * @memberof Commands.game
 */
const saveResults = async (key, games) => {
    const redis = getRedis();
    if (redis == null) {
        logger.error('Can not save game results, redis is unavailable');
        throw { message: 'Redis is unavailable' };
    }

    const data = games.map(game => ({
        text: getTextFromGameDetail(game),
        url: game.background_image,
        name: getNameForButton(game),
    })).reduce((acc, data, i) => {
        acc[i] = JSON.stringify(data);
        return acc;
    }, {});

    return redis.multi()
        .hset(`games:${key}`, data)
        .expire(`games:${key}`, 4 * 60 * 60)
        .exec();
}

/**
 * Get list of games from Redis
 * @param {string} key Redis key for results
 * @param {number} start Starting index
 * @param {number?} stop Last index (including)
 * @returns {Promise<[{[number]: {url: string?, text: string, name: string, released: string}}, number] | [null]>}
 * @memberof Commands.game
 */
const getGamesFromRedis = async (key, start, stop = start + 2) => {
    const redis = getRedis();
    if (redis == null) {
        logger.error('Can not get game results, redis is unavailable');
        throw { message: 'Redis is unavailable' };
    }

    let indexes = range(start, stop + 1);

    try {
        const data = await redis.hmget(`games:${key}`, ...indexes);
        const size = await redis.hlen(`games:${key}`);
        return [
            Object.fromEntries(data
                .map((data, i) => [indexes[i], JSON.parse(data)])
                .filter(([, v]) => v != null)),
            size
        ];
    }
    catch (err) {
        logger.error(`Failed to get games details from [${key}] in range [${start}-${stop}]`, { error: err.stack || err });
        return [null];
    }
}

/**
 * Generate callback data
 * @param {{ key: string, current: number, next: string | number }} data  Callback data inputs
 * @returns {string}
 * @memberof Commands.game
 */
const getCallbackData = (data) => {
    return encodeCallbackData({ prefix: 'game', ...data });
}

/**
 * Get button's name from game details
 * @param {{name: string, released: string?}} - Game details
 * @param {number?} index Game index 
 * @param {number?} selected Current game index selection 
 * @returns {string}
 * @memberof Commands.game
 */
const getNameForButton = ({ name, released }, index = null, selected = null) => {
    let released_date = released == null ? 'TBA' : new Date(released).getFullYear()
    return `${(index != null && index === selected) ? '☑️ ' : ''}${name} (${released_date})`;
}

/**
 * @type {TextDecoderCommon.CommandDefinition}
 * @memberof Commands.game
 */
exports.definition = {
    command_name: 'game',
    args: [
        {
            name: 'query',
            type: 'string',
            description: 'Название для поиска в RAWG.io',
            optional: false
        }
    ],
    limit: 1,
    is_inline: true,
    description: 'Возваращет краткую информацию по игре из RAWG.io'
};

/**
 * @type {boolean}
 * @memberof Commands.game
 */
exports.condition = !!process.env.RAWG_TOKEN;

/**
 * @type {Common.CommandHandler}
 * @memberof Commands.game
 */
exports.handler = async (interaction) => {
    const args = interaction.args?.[0];

    if (!args) {
        return {
            type: 'error',
            text: 'Для запроса нужно предоставить название, например <code>Alan Wake</code>.'
        }
    }

    return searchRAWG({ search: args })
        .then(async (res) => {
            interaction.logger.silly(`Received response from RAWG/games`);
            if (!res.ok) {
                interaction.logger.error(`Non-200 response from RAWG [status:${res.status}] [statusText:${res.statusText}]`, { api_response: JSON.stringify(res) });
                return {
                    type: 'error',
                    text: 'Что-то не задалось с поиском, попробуй ещё раз'
                };
            }
            const json = await res.json();
            if (!json?.results?.length) {
                return {
                    type: 'error',
                    text: 'Не смог ничего найти, попробуй другой запрос'
                };
            }

            const key = genKey();

            if (json.results[0].released !== 'TBA') {
                const hltbInfo = await getHltbInfo({ name: json.results[0].name, year: new Date(json.results[0].released).getFullYear() });
                if (hltbInfo != null) {
                    const playtimes = hltbInfo.timeLabels.map(([key, name]) => ({ name, value: Number.isSafeInteger(hltbInfo[key]) ? hltbInfo[key] : `${Math.floor(hltbInfo[key])}½` }));
                    json.results[0].hltb = {
                        url: `https://howlongtobeat.com/game/${hltbInfo.id}`,
                        playtimes
                    };
                }
            }

            // instead of waiting for all results, return the first one and keep working in the background
            (async () => {
                for (const game of json.results.slice(1, 10)) {
                    if (game.released !== 'TBA') {
                        const hltbInfo = await getHltbInfo({ name: game.name, year: new Date(game.released).getFullYear() });
                        if (hltbInfo != null) {
                            const playtimes = hltbInfo.timeLabels.map(([key, name]) => ({ name, value: Number.isSafeInteger(hltbInfo[key]) ? hltbInfo[key] : `${Math.floor(hltbInfo[key])}½` }));
                            game.hltb = {
                                url: `https://howlongtobeat.com/game/${hltbInfo.id}`,
                                playtimes
                            };
                        }
                    }
                }

                try {
                    await saveResults(key, json.results.slice(0, 10));
                }
                catch (err) {
                    interaction.logger.error('Failed to save game results', { error: err.stack || err });
                }


            })().catch(err => logger.error('/game background has job failed', { error: err.stack || err }));

            let buttons = null;

            buttons = json.results.slice(0, 3).map((game, i) => ([{
                name: getNameForButton(game, i, 0),
                callback: getCallbackData({ key, current: 0, next: i })
            }]));

            if (json.results.length > 3) {
                buttons.push([{
                    name: '⏬',
                    callback: getCallbackData({ key, current: 0, next: `>3` })
                }]);
            }

            return {
                type: 'text',
                text: getTextFromGameDetail(json.results[0]),
                overrides: {
                    link_preview_options: {
                        is_disabled: false,
                        show_above_text: true,
                        url: json.results[0]?.background_image,
                    },
                    buttons,
                    embeded_image: json.results[0]?.background_image,
                }
            };
        })
        .catch((err) => {
            interaction.logger.error(`Error while getting game details from RAWG`, { error: err.stack || err });
            return {
                type: 'error',
                text: 'Что-то у меня поломалось, можешь попробовать ещё раз'
            };
        });
};

/**
 * @type {Common.CommandHandler}
 * @memberof Commands.game
 */
exports.callback = async (interaction) => {
    return listingMenuCallback(interaction, getGamesFromRedis);
}

/**
 * 
 * @param {string} slug Game identifier
 * @returns {Promise<null | {text: string, url?: string}}>}
 */
exports.webapp_callback = async (slug) => {
    const game = await getGameFromRAWG({ slug });
    if (game === null) return null;

    const hltbInfo = await getHltbInfo({ name: game.name, year: new Date(game.released).getFullYear() });
    if (hltbInfo != null) {
        const playtimes = hltbInfo.timeLabels.map(([key, name]) => ({ name, value: Number.isSafeInteger(hltbInfo[key]) ? hltbInfo[key] : `${Math.floor(hltbInfo[key])}½` }));
        game.hltb = {
            url: `https://howlongtobeat.com/game/${hltbInfo.id}`,
            playtimes
        };
    }

    const result = {
        text: getTextFromGameDetail(game),
        url: game.background_image,
    };

    return result;
}