telegram/event-subscriber.js

const { Bot, InlineKeyboard } = require('grammy');
const logger = require('../logger').child({ module: 'telegram-channel-subscriber' });
const { getHealth } = require('../services/health');
const { getRedis } = require('../services/redis');
const { icons, wideSpace } = require('../utils');

const discord_event_map = {};

const chat_event_map = {};

const bot_config = {};
if (process.env?.ENV === 'dev') {
    bot_config.client = {
        buildUrl: (_, token, method) => `https://api.telegram.org/bot${token}/test/${method}`
    }
}

/**
 * @property {Bot?}
 */
const bot = process.env.TELEGRAM_TOKEN ? new Bot(process.env.TELEGRAM_TOKEN, bot_config) : null;

class DiscordEvent {
    constructor(event_data, chat_id) {
        this.current_event_data = null;
        this.chat_id = chat_id;
        this.event_id = event_data.event_id;
        this.event_name = event_data.event_name;
        this.guild_id = event_data.guild_id;
        this.guild_name = event_data.guild_name;
        this.current_update_promise = null;
    }

    get current_message_id() {
        return this._current_message_id;
    }

    set current_message_id(value) {
        if (!chat_event_map[this.chat_id]) {
            chat_event_map[this.chat_id] = new Set();
        }

        if (value === null && this._current_message_id !== null) {
            chat_event_map[this.chat_id].delete(this._current_message_id);
        }
        else {
            chat_event_map[this.chat_id].add(value);
        }

        if (getHealth('redis') === 'ready') {
            const redis = getRedis();
            if (value) {
                redis.hset(`telegram:${this.chat_id}:event_subscriber:message_to_event`, { [value]: this.event_id });
            }
            else {
                redis.hdel(`telegram:${this.chat_id}:event_subscriber:message_to_event`, [this._current_message_id]);
            }
        }

        this._current_message_id = value;
    }

    isNotified() {
        return !!this.current_message_id;
    }

    update(event_data) {
        if (!event_data) {
            return;
        }

        this.current_event_data = event_data;
    }

    getRedirectUrl(discord_url) {
        if (!discord_url) {
            return;
        }
        return discord_url.replace('discord.com', 'dr.bldbr.club/a');
    }

    generateNotificationTextFrom(event_data) {
        if (!event_data) {
            return null;
        }
        let text = `${icons.event}${wideSpace}В Discord начался новый эвент\nНазвание: <a href="${this.getRedirectUrl(event_data.event_url)}">${event_data.event_name}</a>`;

        if (event_data.channel_url) text += `\nКанал: <a href="${this.getRedirectUrl(event_data.channel_url)}">${event_data.channel_name}</a>`;

        if (event_data.event_description) text += `\n${event_data.event_description}`;

        return text;
    }

    getNotificationText(event_data) {
        return event_data ? this.generateNotificationTextFrom(event_data) : this.generateNotificationTextFrom(this.current_event_data);
    }

    generateKeyboardFrom(event_data) {
        return new InlineKeyboard().url(
            'Посетить',
            event_data?.channel_url ? this.getRedirectUrl(event_data.channel_url) : this.getRedirectUrl(event_data.event_url)
        );
    }

    getNotificationKeyboard(event_data) {
        return event_data ? this.generateKeyboardFrom(event_data) : this.generateKeyboardFrom(this.current_event_data);
    }

    getLogMeta() {
        let meta = {};

        meta['discord_event'] = this.event_name;
        meta['discord_event_id'] = this.event_id;
        meta['discord_guild'] = this.guild_name;
        meta['discord_guild_id'] = this.guild_id;
        meta['telegram_chat_id'] = this.chat_id;

        if (this.isNotified()) {
            meta['telegram_message_id'] = this.current_message_id;
        }

        return meta;
    }

    getImage(event_data) {
        return event_data ? event_data?.event_cover_url : this.current_event_data?.event_cover_url;
    }
}

async function restoreMessageID(chat_id, event_id) {
    if (getHealth('redis') !== 'ready') {
        return null;
    }

    const redis = getRedis();

    const message_to_event = await redis.hgetall(`telegram:${chat_id}:event_subscriber:message_to_event`);

    let current_message_id;

    for (const [message_id, event_id_] of Object.entries(message_to_event)) {
        if (event_id_ === event_id) {
            current_message_id = Number(message_id);
            break;
        }
    }

    if (isNaN(current_message_id) || !current_message_id) {
        return null;
    }

    return current_message_id;
}

function getDiscordEvent(event_data, chat_id) {
    if (event_data instanceof DiscordEvent) {
        return event_data;
    }

    if (!discord_event_map[`${chat_id}:${event_data.event_id}`]) {
        discord_event_map[`${chat_id}:${event_data.event_id}`] = new DiscordEvent(event_data, chat_id);
    }

    return discord_event_map[`${chat_id}:${event_data.event_id}`];
}

/**
 * 
 * @param {DiscordEvent} discord_event 
 * @param {*} event_data 
 * @param {'edit' | 'new'} type 
 * @returns {['editMessageText' | 'editMessageCaption' | 'sendMessage' | 'sendPhoto', []]}
 */
function generateAPICall(discord_event, event_data, type = 'new') {
    let method_name = 'sendMessage';
    const args = [discord_event.chat_id];
    const other = {
        link_preview_options: { is_disabled: true },
        parse_mode: 'HTML',
        reply_markup: discord_event.getNotificationKeyboard(event_data)
    };

    if (discord_event.getImage()) {
        method_name = type === 'edit' ? 'editMessageCaption' : 'sendPhoto';
        args.push(type === 'edit' ? discord_event.current_message_id : event_data.event_cover_url);
        other.caption = discord_event.getNotificationText(event_data);

    }
    else {
        method_name = type === 'edit' ? 'editMessageText' : 'sendMessage';
        args.push(type === 'edit' ? discord_event.current_message_id : discord_event.getNotificationText(event_data));
        type === 'edit' && args.push(discord_event.getNotificationText(event_data));
    }

    args.push(other);
    return [method_name, args];
} 

async function editMessage(discord_event, new_event_data) {
    if (!discord_event || !new_event_data) return;

    const [method_name, args] = generateAPICall(discord_event, new_event_data, 'edit');

    discord_event.current_update_promise = bot.api[method_name](...args).then(message => {
        discord_event.update(new_event_data);
        logger.debug(
            `Successful call to ${method_name} about [event: ${discord_event.event_id}] to [chat: ${discord_event.chat_id}], got [message: ${message.message_id}]`,
            { ...discord_event.getLogMeta() }
        );
    }).catch(err => {
        if (err.description.search('message to edit not found') !== -1) {
            logger.debug(`[message: ${discord_event.current_message_id}] doesn't exist, sending new message instead`);
            discord_event.current_message_id = null;
            return sendMessage(discord_event);
        }
        logger.error(
            `Error while calling ${method_name} about [event: ${discord_event.event_id}] to [chat: ${discord_event.chat_id}]`, 
            { error: err.stack || err, ...discord_event.getLogMeta() }
        );
    });

    return discord_event.current_update_promise;
}

async function sendMessage(discord_event) {
    if (!discord_event) return;

    const [method_name, args] = generateAPICall(discord_event, discord_event.current_event_data, 'new');

    discord_event.current_update_promise = bot.api[method_name](...args).then(message => {
        discord_event.current_update_promise = null;
        discord_event.current_message_id = message.message_id;
        logger.debug(
            `Successful call to ${method_name} about [event: ${discord_event.event_id}] to [chat: ${discord_event.chat_id}], got [message: ${message.message_id}]`,
            { ...discord_event.getLogMeta() }
        );
    }).catch(err => {
        discord_event.current_update_promise = null;
        logger.error(
            `Error while calling ${method_name} about [event: ${discord_event.event_id}] to [chat: ${discord_event.chat_id}]`, 
            { error: err.stack || err, ...discord_event.getLogMeta() }
        );
        discord_event.current_event_data = null;
    });

    return discord_event.current_update_promise;
}

async function deleteMessage(discord_event) {
    if (!discord_event?.isNotified()) {
        logger.warn(
            `No event notification to clear about [event: ${discord_event.event_id}] to [chat: ${discord_event.chat_id}]`,
            { ...discord_event.getLogMeta() }
        );
        return;
    }

    return bot.api.deleteMessage(
        discord_event.chat_id,
        discord_event.current_message_id
    ).then(() => {
        logger.debug(
            `Deleted event notification [message: ${discord_event.current_message_id}] about [event:${discord_event.event_id}] in [chat: ${discord_event.chat_id}]`,
            { ...discord_event.getLogMeta() }
        );
        discord_event.current_message_id = null;
    });
}

async function sendNotification(event_data, chat_id) {
    if (!event_data || !chat_id || !bot) return;

    const discord_event = getDiscordEvent(event_data, chat_id);

    if (!discord_event.isNotified()) {
        discord_event.current_message_id = await restoreMessageID(chat_id, event_data.event_id);
    }

    if (!event_data.event_active) {
        return deleteMessage(discord_event);
    }

    if (discord_event.isNotified()) {
        if (discord_event.getNotificationText(event_data) === discord_event.getNotificationText()) {
            logger.debug(
                `Skip event notification about [event: ${discord_event.event_id}]  to [chat: ${discord_event.chat_id}] as equals to current`,
                { ...discord_event.getLogMeta() }
            );
        }
        if (discord_event.current_update_promise !== null) {
            return discord_event.current_update_promise.then(() => {
                logger.debug(
                    `Scheduling event notification update about [event: ${discord_event.event_id}]  to [chat: ${discord_event.chat_id}]`,
                    { ...discord_event.getLogMeta() }
                );
                editMessage(discord_event, event_data);
            });
        }
        return editMessage(discord_event, event_data);
    }

    discord_event.update(event_data);
    return sendMessage(discord_event, event_data);
}

async function deleteNotification(chat_id, event_id) {
    if (!chat_id || !event_id || !bot) return;

    if (!discord_event_map[`${chat_id}:${event_id}`]) return;
    
    deleteMessage(discord_event_map[`${chat_id}:${event_id}`]);
    delete discord_event_map[`${chat_id}:${event_id}`];
}

function isNotificationMessage(chat_id, message_id) {
    if (!chat_id || !message_id) return false;
    return chat_event_map[chat_id] && chat_event_map[chat_id].has(message_id);
}

async function addToReverseMap(guild_id, chat_id) {
    if (!guild_id || !chat_id || !getRedis() || getHealth('redis') !== 'ready') return;

    const redis = getRedis();
    return redis.sadd(`telegram:${chat_id}:event_subscriber:guild_ids`, guild_id);
}

function removeFromReverseMap(guild_id, chat_id) {
    if (!guild_id || !chat_id || !getRedis() || getHealth('redis') !== 'ready') return;

    const redis = getRedis();
    return redis.srem(`telegram:${chat_id}:event_subscriber:guild_ids`, guild_id);
}

function getReverseMap(chat_id) {
    if (!chat_id || !getRedis() || getHealth('redis') !== 'ready') return;
    
    const redis = getRedis(); 
    return redis.smembers(`telegram:${chat_id}:event_subscriber:guild_ids`);
}

module.exports = {
    sendNotification,
    deleteNotification,
    isNotificationMessage,
    ReverseMap: {
        add: addToReverseMap,
        remove: removeFromReverseMap,
        get: getReverseMap
    }
};