telegram/channel-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');

/**
 * Channel Subscriber
 * @namespace ChannelSubscriber
 */

const discord_notification_map = {};

const chat_notification_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?}
 * @memberof ChannelSubscriber
 */
const bot = process.env.TELEGRAM_TOKEN ? new Bot(process.env.TELEGRAM_TOKEN, bot_config) : null;

/**
 * Restores the last used message id for notification in telegram chat from Redis
 * @param {string} chat_id - Telegram chat id
 * @param {string} channel_id - Id of the discord channel that triggered notification
 * @returns {number | null} message id in the telegram chat
 * @memberof ChannelSubscriber
 */
async function restoreMessageID(chat_id, channel_id) {
    if (getHealth('redis') !== 'ready') {
        return null;
    }

    const redis = getRedis();

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

    let current_message_id;

    for (const [message_id, channel_id_] of Object.entries(message_to_channel)) {
        if (channel_id_ === channel_id) {
            current_message_id = Number(message_id);
            break;
        }
    }

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

    return current_message_id;
}

/**
 * @typedef {object} DiscordNotificationData
 * @property {string} channel_id
 * @property {string} channel_name
 * @property {string} channel_url
 * @property { 'voice' | 'text' | 'announcements' | 'forum' | 'stage' | undefined } channel_type
 * @property {string} guild_id
 * @property {string} guild_name
 * @property {object[]} members
 * @property {string} members[].user_id
 * @property {string} members[].user_name
 * @property {boolean} members[].streaming
 * @property {string} members[].member_id
 * @property {string} members[].member_name
 * @property {boolean} members[].muted
 * @property {boolean} members[].deafened
 * @property {boolean} members[].camera
 * @property {string?} members[].activity
 * @memberof ChannelSubscriber
 */

/**
 * Discord Notification
 * @class
 * @memberof ChannelSubscriber
*/
class DiscordNotification {
    /**
     * @param {ChannelSubscriber.DiscordNotificationData} notification_data - Channel state data from discord
     * @param {string} chat_id - Telegram chat id
    */
    constructor(notification_data, chat_id) {
        /**  @member {ChannelSubscriber.DiscordNotificationData?} - The source of the currently (or about to be) posted notification */
        this.current_notification_data = null;
        /** @member {string} - Telegram chat id */
        this.chat_id = chat_id;
        /** @member {string} - Discord channel id  */
        this.channel_id = notification_data.channel_id;
        /**  @member {string} - Discord channel name */
        this.channel_name = notification_data.channel_name;
        /**  @member {string} - Discord server id */
        this.guild_id = notification_data.guild_id;
        /**  @member {string} - Discord name id */
        this.guild_name = notification_data.guild_name;

        /** @member {number} - The time it takes to cooldown from update */
        this.cooldown_duration = 5 * 1000;
        /** @member {NodeJS.Timeout} - Timer controlling the state of the cooldown */
        this.cooldown_timer = null;
        /** @member {number} - The time when the current cooldown will finnish (or has finished) */
        this.cooldown_timeout = 0;

        this._current_message_id = null;

        /** @member {ChannelSubscriber.DiscordNotificationData} - The source for the next notification update that will be applied after the cooldown */
        this.pending_notification_data = null;
        /** @member {NodeJS.Timeout} - Scheduling timer that will update the notification message once it fires */
        this.pending_notification_data_timer = null;
    }

    /**  @member {string} - Telegram message id for the currently posted notification */
    get current_message_id() {
        return this._current_message_id;
    }

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

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

        chat_notification_map[this.chat_id].add(value);

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

        this._current_message_id = value;
    }

    /** @member {string} - Current discord channel url */
    get channel_url() {
        return this.current_notification_data?.channel_url;
    }

    /** @member {object[]}  - Array of members of the discord channel  */
    get members() {
        return this.current_notification_data?.members;
    }

    /**
     * Returns `true` if the notification is posted 
     * @returns {boolean}
     */
    isNotified() {
        return !!this.current_message_id;
    }

    /**
     * Returns `true` if the cooldown is active
     * @returns {boolean}
     */
    isCooldownActive() {
        return this.cooldown_timer != null;
    }

    /**
     * Resets {@link cooldown_timer}
     */
    startCooldownTimer() {
        clearTimeout(this.cooldown_timer);
        this.cooldown_timer = setTimeout(() => {
            this.cooldown_timer = null;
            this.cooldown_timeout = 0;
        }, this.cooldown_duration);
        this.cooldown_timeout = Date.now() + this.cooldown_duration;
    }

    /**
     * Sets the new current notification data and resets the cooldown timer
     * @param {ChannelSubscriber.DiscordNotificationData} notification_data - New notification data
     */
    update(notification_data) {
        if (!notification_data) {
            return;
        }

        this.current_notification_data = notification_data;

        this.startCooldownTimer();
    }

    /**
     * Clear all the timers and notification data
     * @returns {string} Last {@link current_message_id}
     */
    clear() {
        clearTimeout(this.pending_notification_data_timer);
        clearTimeout(this.cooldown_timer);

        this.pending_notification_data_timer = null;
        this.current_notification_data = null;
        this.cooldown_timer = null;

        const current_message_id = `${this.current_message_id}`;

        this.current_message_id = null;

        return current_message_id;
    }

    /**
     * Get discord channel url
     * @param {ChannelSubscriber.DiscordNotificationData} notification_data - Source notification data to get url from
     * @param {'a' | 'b' | null} type - Type of a URL to get, 'a' - to app, 'b' - to browser, anything else for redirect page
     * @returns {string} Returns url (may be changed to support application redirect)
     */
    getChannelUrl(notification_data, type) {
        if(!notification_data) {
            return null;
        }

        if (type === 'a') {
            return notification_data.channel_url.replace('discord.com', 'dr.bldbr.club/a');
        }
        else if (type === 'b') {
            return notification_data.channel_url.replace('discord.com', 'dr.bldbr.club/b');
        }

        return notification_data.channel_url.replace('discord.com', 'dr.bldbr.club');
    }

    /**
     * Generate message text from notification data
     * @param {ChannelSubscriber.DiscordNotificationData} notification_data - Source notification data for text
     * @returns {string} Message text
     */
    generateNotificationTextFrom(notification_data) {
        if (!notification_data) {
            return null;
        }
        let text = `<b>${notification_data.channel_name}</b>`;
        let icon;
        if (notification_data.channel_type && (icon = (icons[notification_data.channel_type] || icons[`${notification_data.channel_type}_channel`])) ) {
            text = `${icon}${wideSpace}${text}`
        }

        notification_data.members.forEach((member) => {
            text += `\n${wideSpace}${member.member_name || member.user_name}`
                + (this.transformStatus(member) ? `${wideSpace}${this.transformStatus(member)}` : '')
                + (member.activity ? `${wideSpace}— <i>${member.activity}</i>` : '');
        });

        return text;
    }

    /**
     * Transform statuses to string with emojis
     * @param {object} params
     * @param {boolean} params.muted true if user is muted
     * @param {boolean} params.deafened true if user is deafened
     * @param {boolean} params.camera true if user's camera is on
     * @param {boolean} params.streaming true if user is streaming
     */
    transformStatus({ muted, deafened, camera, streaming }) {
        return (muted ? icons.mic_off : '')
            + (deafened ? icons.sound_off : '')
            + (camera ? icons.video_on : '')
            + (streaming ? icons.live : '');
    }

    /**
     * Get message text for {@link current_notification_data}
     * @returns {string} Message text
     */
    getNotificationText() {
        return this.generateNotificationTextFrom(this.current_notification_data);
    }

    /**
     * Get message text for {@link pending_notification_data}
     * @returns {string} Pending message text
     */
    getPendingNotificationText() {
        return this.generateNotificationTextFrom(this.pending_notification_data);
    }

    /**
     * Get keyboard for the message with channel url
     * @returns {import('grammy').InlineKeyboard}
     */
    getNotificationKeyboard() {
        return new InlineKeyboard()
            .url(
                'В браузер',
                this.getChannelUrl(this.current_notification_data, 'b')
            ).url(
                'В приложение',
                this.getChannelUrl(this.current_notification_data, 'a')
            );
    }

    /**
     * Schedule message update after cooldown
     * @param {ChannelSubscriber.DiscordNotificationData} notification_data - new {@link pending_notification_data}
     * @param {(DiscordNotification) => Promise} callback - Callback that should be called when {@link pending_notification_timer} fires
     */
    suspendNotification(notification_data, callback) {
        clearTimeout(this.pending_notification_data_timer);

        this.pending_notification_data = notification_data;
        this.pending_notification_data_timer = setTimeout(() => {
            this.update(this.pending_notification_data);
            callback(this);
            this.pending_notification_data = null;
            this.pending_notification_timer = null;
        }, this.cooldown_timeout - Date.now());
    }

    /**
     * Clear current {@link pending_notification_data} and {@link pending_notification_data_timer}
     */
    dropPendingNotification() {
        clearTimeout(this.pending_notification_data_timer);

        this.pending_notification_data = null;
        this.pending_notification_timer = null;
    }

    /**
     * Get additional logging info
     * @returns {object} Object containing info about this {@link DiscordNotificationData}
     */
    getLogMeta() {
        let meta = {};

        meta['discord_channel'] = this.channel_name;
        meta['discord_channel_id'] = this.channel_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['pending_notification_data_exists'] = !!this.pending_notification_data;
            meta['telegram_message_id'] = this.current_message_id;
        }

        return meta;
    }
}

/**
 * Pin notification message in chat
 * @param {ChannelSubscriber.DiscordNotification} discord_notification 
 * @returns {Promise<Message>}
 * @memberof ChannelSubscriber
 */
function pinNotificationMessage(discord_notification) {
    return bot.api.pinChatMessage(
        discord_notification.chat_id,
        discord_notification.current_message_id,
        {
            disable_notification: true,
        }
    ).then(() => {
        logger.debug(
            `Pinned [message: ${discord_notification.current_message_id}] about [channel:${discord_notification.channel_id}] in [chat: ${discord_notification.chat_id}]`,
            { ...discord_notification.getLogMeta() }
        );
    }).catch((err) => {
        logger.error(
            `Error while pinning [message: ${discord_notification.current_message_id}] about [channel:${discord_notification.channel_id}] in [chat: ${discord_notification.chat_id}]`,
            { error: err.stack || err, ...discord_notification.getLogMeta() }
        );
    });
}

/**
 * Get {@link DiscordNotification} for supplied notification_data
 * @param {ChannelSubscriber.DiscordNotificationData | ChannelSubscriber.DiscordNotification | null} notification_data - Source data
 * @param {string | number} chat_id - Telegram chat id
 * @returns {ChannelSubscriberDiscordNotification}
 * @memberof ChannelSubscriber
 */
function getDiscordNotification(notification_data, chat_id) {
    if (notification_data instanceof DiscordNotification) {
        return notification_data;
    }

    if (!discord_notification_map[`${chat_id}:${notification_data.channel_id}`]) {
        discord_notification_map[`${chat_id}:${notification_data.channel_id}`] = new DiscordNotification(notification_data, chat_id);
    }

    return discord_notification_map[`${chat_id}:${notification_data.channel_id}`];
}

/**
 * Delete notification message from telegram chat
 * @param {ChannelSubscriberDiscordNotification} discord_notification - {@link DiscordNotification} that is associated with notification message
 * @returns {Promise}
 * @memberof ChannelSubscriber
 */
function clearNotification(discord_notification) {
    if (!discord_notification.isNotified()) {
        logger.debug(
            `No channel state notification to clear about [channel:${discord_notification.channel_id}] in [chat:${discord_notification.chat_id}]`,
            { ...discord_notification.getLogMeta() }
        );
        return;
    }

    return bot.api.deleteMessage(
        discord_notification.chat_id,
        discord_notification.current_message_id
    ).then(() => {
        logger.debug(
            `Deleted channel state notification [message: ${discord_notification.current_message_id}] about [channel:${discord_notification.channel_id}] in [chat: ${discord_notification.chat_id}]`,
            { ...discord_notification.getLogMeta() }
        );
        discord_notification.clear();
    }).catch(err => {
        logger.error(
            `Error while clearing channel state notification [message: ${discord_notification.current_message_id}] about [channel_id: ${discord_notification.channel_id}] in [chat: ${discord_notification.chat_id}]`,
            { error: err.stack || err, ...discord_notification.getLogMeta(), telegram_message_id: discord_notification.current_message_id }
        );
    });
}

/**
 * Send notification message to telegram chat
 * @param {ChannelSubscriber.DiscordNotification} discord_notification - {@link DiscordNotification} that is associated with notification message
 * @returns {Promise}
 * @memberof ChannelSubscriber
 */
function sendNotificationMessage(discord_notification) {
    return bot.api.sendMessage(
        discord_notification.chat_id,
        discord_notification.getNotificationText(),
        {
            link_preview_options: { is_disabled: true },
            parse_mode: 'HTML',
            reply_markup: discord_notification.getNotificationKeyboard()
        }
    ).then((message) => {
        discord_notification.current_message_id = message.message_id;
        logger.debug(
            `Sent channel state notification about [channel:${discord_notification.channel_id}] to [chat: ${discord_notification.chat_id}], got [message: ${message.message_id}]`,
            { ...discord_notification.getLogMeta() }
        );
        pinNotificationMessage(discord_notification);
    }).catch((err) => {
        logger.error(
            `Error while sending channel state notification about [channel: ${discord_notification.channel_id}] to [chat: ${discord_notification.chat_id}]`,
            { error: err.stack || err, ...discord_notification.getLogMeta() }
        );
    });
}

/**
 * Edit existing notification message with current notification data 
 * @param {ChannelSubscriber.DiscordNotification} discord_notification - {@link DiscordNotification} that is associated with notification message
 * @returns {Promise}
 * @memberof ChannelSubscriber
 */
function editNotificationMessage(discord_notification) {
    return bot.api.editMessageText(
        discord_notification.chat_id,
        discord_notification.current_message_id,
        discord_notification.getNotificationText(),
        {
            link_preview_options: { is_disabled: true },
            parse_mode: 'HTML',
            reply_markup: discord_notification.getNotificationKeyboard()
        }
    ).then((message) => {
        discord_notification.current_message_id = message.message_id;
        logger.debug(
            `Edited [message: ${discord_notification.current_message_id}] about [channel:${discord_notification.channel_id}] in [chat: ${discord_notification.chat_id}]`,
            { ...discord_notification.getLogMeta() }
        );
    }).catch((err) => {
        if (err.description.search('message to edit not found') !== -1) {
            logger.info(`[message: ${discord_notification.current_message_id}] doesn't exist, sending new message instead`);
            discord_notification.current_message_id = null;
            return sendNotificationMessage(discord_notification);
        }
        logger.error(
            `Error while editing [message: ${discord_notification.current_message_id}] about [channel:${discord_notification.channel_id}] in [chat: ${discord_notification.chat_id}]`, 
            { error: err.stack || err, ...discord_notification.getLogMeta() }
        );
    });
}

/**
 * Wraps the send / update of the notificatin data to telegram chat
 * @param {ChannelSubscriber.DiscordNotification} notification_data - Source notification data
 * @param {number | string} chat_id - Telegram chat id
 * @returns {Promise}
 * @memberof ChannelSubscriber
 */
async function wrapInCooldown(notification_data, chat_id) {
    const discord_notification = getDiscordNotification(notification_data, chat_id);

    if (discord_notification.isNotified()) {
        if (discord_notification.generateNotificationTextFrom(notification_data) == discord_notification.getNotificationText()) {
            logger.debug(
                `Skipping channel state notification about [channel: ${discord_notification.channel_id}] to [chat: ${discord_notification.chat_id}] as equals to current`,
                { ...discord_notification.getLogMeta() }
            );
            discord_notification.dropPendingNotification();
            return;
        }

        if (discord_notification.isCooldownActive()) {
            logger.debug(
                `Suspending channel state notification about [channel: ${discord_notification.channel_id}] to [chat: ${discord_notification.chat_id}]`,
                { ...discord_notification.getLogMeta() }
            );
            discord_notification.suspendNotification(notification_data, editNotificationMessage);
            return;
        }
    }
    else {
        discord_notification.current_message_id = await restoreMessageID(chat_id, discord_notification.channel_id);
    }
    
    
    discord_notification.update(notification_data);

    if (discord_notification.isNotified()) {
        return editNotificationMessage(discord_notification);
    }
    else {
        return sendNotificationMessage(discord_notification);
    }
}

/**
 * Interface for sending / updating the notification message from notification data
 * @param {ChannelSubscriber.DiscordNotificationData} notification_data 
 * @param {number | string} chat_id 
 * @returns {Promise}
 * @memberof ChannelSubscriber
 */
async function sendNotification(notification_data, chat_id) {
    if (!notification_data || !chat_id || !bot) return;

    if (!notification_data.members.length) {
        clearNotification(getDiscordNotification(notification_data, chat_id));
        return;
    }

    return wrapInCooldown(notification_data, chat_id);
}

/**
 * Interface for deleting the notification data for discord channe from telegram chat
 * @param {number | string} chat_id 
 * @param {string} channel_id 
 * @returns {Promise}
 * @memberof ChannelSubscriber
 */
async function deleteNotification(chat_id, channel_id) {
    if (!chat_id || !channel_id || !bot) return;

    if (!discord_notification_map[`${chat_id}:${channel_id}`]) return;

    clearNotification(discord_notification_map[`${chat_id}:${channel_id}`]);
    delete discord_notification_map[`${chat_id}:${channel_id}`]
}

/**
 * Interface for checking that telegram message in chat is a notification message
 * @param {string | number} chat_id 
 * @param {string | number} message_id 
 * @returns {boolean}
 * @memberof ChannelSubscriber
 */
function isNotificationMessage(chat_id, message_id) {
    if (!chat_id || !message_id) {
        return false;
    }
    return chat_notification_map?.[chat_id]?.has(message_id) || false;
}

module.exports = {
    sendNotification,
    deleteNotification,
    isNotificationMessage
}