telegram/telegram-client.js

const { Bot, Context, webhookCallback, InputFile } = require('grammy');
const { promises: fs } = require('fs');
const { hydrateFiles } = require('@grammyjs/files');
const TelegramHandlers = require('./telegram-handler');
const config = require('../config.json');
const { setHealth } = require('../services/health');
const { handleCommand, getLegacyResponse, handleCallback } = require('../commands/telegram');
const { commands, conditions, definitions, handlers, callbacks } = require('../commands/handlers-exporter');
const ChatLLMHandler = require('./llm-handler.js');
const { isNotificationMessage: isChannelNotificationMessage } = require('./channel-subscriber.js');
const { isNotificationMessage: isEventNotificationMessage } = require('./event-subscriber.js');
const { used: tinkovUsed } = require('./command-handlers/tinkov-handler.js');
const { to, convertMD2Nodes } = require('../utils');

const no_tags_regex = /<\/?[^>]+(>|$)/g;

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

const media_types = [
    'audio',
    'animation',
    'chat_action',
    'contact',
    'dice',
    'document',
    'game',
    'invoice',
    'location',
    'photo',
    'poll',
    'sticker',
    'venue',
    'video',
    'video_note',
    'voice',
    'text',
];

const inline_answer_media_types = [
    'animation',
    'audio',
    'video',
    'document',
    'voice',
    'photo',
    'gif',
    'sticker',
    'mpeg4_gif'
];

const inline_media_requiring_thumbnail = [
    'photo',
    'video',
    'gif',
    'animation',
    'mpeg4_gif'
];

/**
 * One time use interaction between app and telegram
 * @property {TelegramClient} this.client
 * @property {String?} this.command_name
 * @property {Context?} this.context
 */
class TelegramInteraction {
    /**
     * One time use interaction between app and telegram
     * @param {TelegramClient} client
     * @param {String} [command_name]
     * @param {Context} [context]
     */
    constructor(client, command_name, context) {
        this.client = client;
        this.log_meta = {
            module: 'telegram-interaction',
            command_name: command_name,
            telegram_chat_id: context?.chat?.id,
            telegram_message_id: context?.message?.message_id,
            telegram_user_id: context?.from?.id,
        };
        this.logger = require('../logger').child(this.log_meta);
        this.command_name = command_name;
        this.context = context;
        this._redis = client.redis;
        this._currencies_list = client.currencies_list;

        if (context) {
            this.mediaToMethod = {
                'audio': this.context.replyWithAudio.bind(this.context),
                'animation': this.context.replyWithAnimation.bind(this.context),
                'chat_action': this.context.replyWithChatAction.bind(this.context),
                'contact': this.context.replyWithContact.bind(this.context),
                'dice': this.context.replyWithDice.bind(this.context),
                'document': this.context.replyWithDocument.bind(this.context),
                'game': this.context.replyWithGame.bind(this.context),
                'invoice': this.context.replyWithInvoice.bind(this.context),
                'location': this.context.replyWithLocation.bind(this.context),
                'media_group': this.context.replyWithMediaGroup.bind(this.context),
                'photo': this.context.replyWithPhoto.bind(this.context),
                'poll': this.context.replyWithPoll.bind(this.context),
                'sticker': this.context.replyWithSticker.bind(this.context),
                'venue': this.context.replyWithVenue.bind(this.context),
                'video': this.context.replyWithVideo.bind(this.context),
                'video_note': this.context.replyWithVideoNote.bind(this.context),
                'voice': this.context.replyWithVoice.bind(this.context),
            };
        }
    }

    get inline_commands() {
        return this.client.inline_commands;
    }

    /**
     * @returns {Map<'string', 'string'[]>}
     */
    get registered_commands() {
        return this.client.registered_commands;
    }

    /**
     * @returns {Telegram}
     */
    get api() {
        return this.client.client.api;
    }

    /**
     * 
     * @param {import('grammy/types').Message} message 
     */
    getWithEntities(message, goodEntities) {
        let original = message?.text || message?.caption || null;
        if (!original?.length) return null;
        let text = '';

        let cursor = 0;
        let entities = goodEntities.sort((a, b) => a.offset - b.offset || b.length - a.length);
        for (const entity of entities) {
            if (cursor < entity.offset) {
                text += original.slice(cursor, entity.offset);
            }
            text += to[entity.type](original.slice(entity.offset, entity.offset + entity.length), 'html', entity);
            cursor = entity.offset + entity.length;
        }

        if (cursor < entities.slice(-1).offset) {
            text += original.slice(entity.offset + entity.length);
        }
        return text;
    }

    /**
     * 
     * @param {import('grammy/types').Message} message 
     * @returns 
     */
    _parseMessageMedia(message) {
        if (!message) return;

        const parsed_media = {};

        parsed_media.text = message.text || message.caption;
        
        let goodEntities = (message?.entities || message?.caption_entities || [])?.filter(e => [
            'bold', 'italic', 'underline', 'strikethrough', 'spoiler', 
            'blockquote', 'code', 'pre', 'text_link'
        ].includes(e.type));

        if (goodEntities.length) {
            parsed_media.text = this.getWithEntities(message, goodEntities);
        }

        parsed_media.type = Object.keys(message).filter(key => media_types.includes(key))[0];

        if (parsed_media.type === 'photo') {
            parsed_media.media = message.photo[0].file_id;
        }
        else if (parsed_media.type !== 'text') {
            parsed_media.media = message[parsed_media.type].file_id;
        }

        return parsed_media;
    }

    _getBasicMessageOptions() {
        return {
            allow_sending_without_reply: true,
            reply_parameters: {
                message_id: this.context.message?.reply_to_message?.message_id || this.context.message?.message_id,
                allow_sending_without_reply: true
            }
        };
    }

    _getTextOptions() {
        return {
            parse_mode: 'HTML',
            link_preview_options: { is_disabled: true }
        };
    }

    getDefaultOther(overrides) {
        return {
            parse_mode: 'HTML',
            ...overrides,
            reply_parameters: {
                allow_sending_without_reply: true,
                message_id: this.context.message?.reply_to_message?.message_id || this.context.message?.message_id,
                ...overrides?.reply_parameters
            },
            link_preview_options: {
                is_disabled: true,
                ...overrides?.link_preview_options
            },
        }
    }

    /**
     * Get reply method associated with content type
     * @param {String} media_type 
     * @return {() => Promise}
     */
    _getReplyMethod(media_type) {
        return this.mediaToMethod[media_type];
    }

    /**
     * Reply to message with text
     * @param {String} text text to send
     * @return {Promise<Message>}
     */
    async _reply(text, overrides) {
        this.logger.info(`Replying with text`);
        return this.context.reply(text, {
            ...this.getDefaultOther(overrides),
            ...overrides,
            original: undefined
        });
    }

    /**
     * Reply to message with media group
     * 
     * @param {Object} message contains media group 
     * @param {Object | null} overrides 
     * @returns {Promise<Message>}
     */
    async _replyWithMediaGroup(message, overrides) {
        if (message.type === 'text') {
            return this._reply(message.text, overrides)
        }

        const message_options = {
            ...this._getBasicMessageOptions(),
            ...overrides,
            original: undefined
        }

        const media = message.media.filter((singleMedia) => {
            if (['audio', 'document', 'photo', 'video'].includes(singleMedia.type)) {
                return singleMedia;
            }
        });

        if (!media.length) {
            this.logger.warn(`No suitable media found in [${JSON.stringify(message)}]`);
            return this._reply(message.text);
        }

        media[0] = {
            ...media[0],
            ...this._getTextOptions(),
            ...overrides,
            original: undefined
        };

        if (message.text) {
            media[0].caption = media[0].caption ? `${media[0].caption}\n${message.text}` : message.text;
        }

        this.logger.info(`Replying with media group of type: ${message.type}`, { response: media });
        return this.context.replyWithMediaGroup(media, message_options);
    }

    /**
     * Reply to message with media file
     * 
     * @param {Object} message may contain text and an id of one of `[animation, audio, document, video, video_note, voice, sticker]`
     * @return {Promise<Message>}
     */
    async _replyWithMedia(message, overrides) {
        if (message.type === 'text') {
            return this._reply(message.text, overrides);
        }

        if (message.type === 'media_group') {
            return this._replyWithMediaGroup(message, overrides);
        }

        let message_options = {
            caption: message.text,
            ...this._getBasicMessageOptions(),
            ...this._getTextOptions(),
            ...overrides,
            ...message.overrides,
            original: undefined
        };

        let media;

        if (message.filename || message.path) {
            this.logger.info(`Replying with file of type: ${message.type}`);
            media = new InputFile(message.media || message.path, message.filename);
        }
        else {
            this.logger.info(`Replying with media of type: ${message.type}`);
            media = message.media || message[message.type];
        }

        const replyMethod = this._getReplyMethod(message.type);

        const deleteTempFile = () => {
            if (!message.path) return;
            return fs.rm(message.path).then(() => {
                this.logger.debug('Deleted temp file');
            }).catch((e) => {
                this.logger.error(`Could not delete temp file: ${message.path}`, { error: e.stack || e });
            });
        }

        if (typeof replyMethod === 'function') {
            return replyMethod(media, message_options).finally(deleteTempFile);
        }

        deleteTempFile();

        this.logger.info(`Can't send message as media`);
        return this._reply(message.text);
    }

    /**
     * Reply with link to Telegra.ph article
     * @param {string} text 
     * @param {{original?: {text: string, parse_mode?: 'html' | 'markdown'} }} overrides
     * @param {'html' | 'markdown'} parse_mode
     */
    async _replyWithArticle(_text, overrides, _parse_mode = 'html') {
        if (process.env.TELEGRAPH_TOKEN == null) {
            throw 'Too long text';
        }

        const { Telegraph, parseHtml } = await import('better-telegraph');
        const telegraph = new Telegraph({ accessToken: process.env.TELEGRAPH_TOKEN });
        const parse_mode = overrides.original?.parse_mode || _parse_mode;
        let text = overrides.original?.text || _text;
        if (parse_mode === 'html') {
            text = text.replace('<pre><code', '<code').replace('</code></pre>', '</code>');
        }

        const content = parse_mode !== 'html'
            ? convertMD2Nodes(text)
            : parseHtml(text);
        
        let title = 'Bilderberg Butler';
        if (typeof content === 'string') {
            title = content;
        }
        else if (['h3', 'h4', 'p'].includes(content[0].tag) && typeof content[0].children[0] === 'string' ) {
            title = content[0].children[0];
        }

        title = title.split(' ').slice(0, 5).join(' ').slice(0, 256);

        try {
            const { url } = await telegraph.create({
                title,
                content,
                author_name: this.client.client.botInfo.first_name,
                author_url: `https://t.me/${this.client.client.botInfo.username}`,
            });

            return this._reply(url, { 
                link_preview_options: { is_disabled: false }
            });
        }
        catch (err) {
            this.logger.error('Failed to create an article', { error: err.stack || err });
            return null;
        }
    }

    /**
     * High level function for replying to Telegram-specific commands
     * Returns undefined or promise for reply request
     */
    reply() {
        if (typeof TelegramHandlers[this.command_name]?.handler !== 'function') {
            this.logger.warn(`Received nonsense, how did it get here???`);
            return;
        }

        this.logger.info(`Received command: ${this.command_name}`);

        TelegramHandlers[this.command_name].handler(this.context, this).then(([err, response, callback, overrides]) => {
            if (!callback) callback = () => {};
            if (err == 'skip') {
                return;
            }
            else if (err) {
                return this._reply(err, overrides).catch((err) => {
                    this.logger.error(`Error while replying with an error message to [${this.command_name}]`, { error: err.stack || err });
                    this._reply(`Что-то случилось:\n<code>${err}</code>`).catch((err) => this.logger.error(`Safe reply failed`, { error: err.stack || err }));
                }).then((response_message) => {
                    if (CLEAR_ERROR_MESSAGE_TIMEOUT > 0) {
                        setTimeout(() => {
                            // clear request message if only command is there
                            if ((this.context.message.text || this.context.message.caption)?.split(' ')?.length === 1) {
                                this.context.deleteMessage().catch(() => {});   
                            }
                            this.context.api.deleteMessage(response_message.chat.id, response_message.message_id).catch(() => {});
                        }, CLEAR_ERROR_MESSAGE_TIMEOUT);
                    }
                }).then(callback);
            }
            else if (response instanceof String || typeof response === 'string') {
                return this._reply(response, overrides).catch(err => {
                    if (!err?.description?.includes('message is too long')) throw err;
                    return this._replyWithArticle(overrides.original?.text || response, overrides);
                }).catch(err => {
                    this.logger.error(`Error while replying with response text to [${this.command_name}]`);
                    this._reply(`Что-то случилось:\n<code>${err}</code>`).catch((err) => this.logger.error(`Safe reply failed`, { error: err.stack || err }));
                }).then(callback);
            }
            else if (Array.isArray(response)) {
                return this._replyWithMedia(response[0], overrides).catch(err => {
                    this.logger.error(`Error while replying with single media from an array to [${this.command_name}]`);
                    this._reply(`Что-то случилось:\n<code>${err}</code>`).catch((err) => this.logger.error(`Safe reply failed`, { error: err.stack || err }));
                })
            }
            else if (response instanceof Object) {
                return this._replyWithMedia(response, overrides).catch(err => {
                    this.logger.error(`Error while replying with media to [${this.command_name}]`);
                    this._reply(`Что-то случилось:\n<code>${err}</code>`).catch((err) => this.logger.error(`Safe reply failed`, { error: err.stack || err }));
                }).then(callback);
            }
        }).catch((err) => {
            this.logger.error(`Error while processing command [${this.command_name}]`, { error: err.stack || err });
            this._reply(`Что-то случилось:\n<code>${err}</code>`).catch((err) => this.logger.error(`Safe reply failed`, { error: err.stack || err }));
        });
    }

    /**
     * Generate inline query result from text ("article")
     * @param {String} text 
     * @param {Object} overrides 
     * @returns {Object}
     */
    _generateInlineText(text, overrides) {
        let result = {
            id: Date.now(),
            type: 'article',
            title: text.split('\n')[0].replace(no_tags_regex, ''),
            input_message_content: {
                message_text: text,
                ...this._getTextOptions(),
                ...overrides,
                original: undefined
            },
            ...this._getTextOptions(),
            ...overrides,
            original: undefined
        };

        return result;
    }

    /**
     * Generate inline query result from media
     * @param {Object} media 
     * @param {Object?} overrides 
     * @returns {Object}
     */
    _generateInlineMedia(media, overrides) {
        if (media.type === 'text') return this._generateInlineText(media.text, overrides);

        if (!inline_answer_media_types.includes(media.type)) {
            this.logger.warn(`Can't answer inline query with media of type: ${media.type}`);
            return;
        }

        let suffix = media.url ? '_url' : '_file_id';
        let data = media.url ? media.url : media.media || media[media.type];
        let inline_type = media.type === 'animation' ? 'gif' : media.type;
        let thumbnail_url = media.thumbnail_url || (inline_media_requiring_thumbnail.includes(media.type) && config.DEFAULT_THUMBNAIL_URL);
        let result = {
            id: Date.now(),
            type: inline_type,
            title: media.text ? media.text.split('\n')[0] : ' ',
            caption: media.text,
            thumbnail_url,
            ...this._getTextOptions(),
            ...overrides,
            ...media.overrides,
            original: undefined
        };
        result[`${inline_type}${suffix}`] = data;

        for (let key in result) {
            if (!result[key]) {
                delete result[key];
            }
        }

        if (!result.title) {
            result.title = ' ';
        }

        return result;
    }

    /**
     * Asnweres inline query accroding to passed results
     * @param {Object[]} results_array 
     * @param {Object?} overrides 
     * @returns {undefined | Promise}
     */
    async _answerQuery(results_array, overrides) {
        if (!results_array) {
            return;
        }

        const answer = {
            results: results_array,
            other: {
                cache_time: 0,
                ...overrides,
                original: undefined
            }
        }

        this.logger.info(`Responding to inline query with an array`);

        return this.context.answerInlineQuery(answer.results, answer.other);
    }

    /**
     * High level command for answering inline query
     * Returns nothing or the promise for answerInlineQuery
     * @returns {undefined | Promise}
     */
    async answer() {
        this.logger.debug(`Received inline query [${this.context.inlineQuery.query}]`);

        // fist stage, getting command mathes
        const query = this.context.inlineQuery.query;
        const first_word = ((a) => a.length ? a[0] : '/')(query.split(' '));
        const matching_command_names = this.inline_commands.filter((command_name) => `/${command_name}`.startsWith(first_word));
        const command_name = first_word.slice(1);
        this.logger.silly(`List of matching commands: [${JSON.stringify(matching_command_names)}] for first word ${first_word}`);

        // if multiple commands are matching, answer with help
        if (matching_command_names.length > 0 && !this.inline_commands.includes(command_name)) {
            let results = [];
            for (const matching_command_name of matching_command_names) {
                this.registered_commands.forEach((help, command_name) => {
                    if (command_name !== matching_command_name || !help.length) return;
                    let line = `/${command_name} ${help.length > 1 ? help.slice(0, -1).join(' ') : ''}`;
                    results.push(
                        this._generateInlineText(
                            line,
                            {
                                description: help.slice(-1)[0],
                                id: `${matching_command_name}${require('../package.json').version}${process.env.ENV}`,
                                message_text: `<code>@${this.context.me.username} ${line}</code>\n<i>${help.slice(-1)[0]}</i>`,
                            }
                        )
                    );
                });
            }

            if (!results.length) {
                this.logger.silly(`No help is generated for the inline query, exiting`);
                return;
            }

            return this._answerQuery(results).catch((err) => {
                this.logger.error(`Error while answering the inline query`, { error: err.stack || err });
            });
        }

        // nothing matches, exit
        if (!matching_command_names.length || !this.inline_commands.includes(command_name)) {
            this.logger.silly(`Inline query doesn't match any command, sending empty answer`);
            return this._answerQuery([], { cache_time: 0 }).catch(err => {
                this.logger.error(`Error while sending empty response for inline query`, { error: err.stack || err });
            });
        }


        // second stage, when this is a command
        let parsed_context = {
            chat: {
                id: this.context.inlineQuery.from.id
            },
            from: this.context.inlineQuery.from,
            message: {
                text: query
            },
            type: 'private'
        };

        this.logger.info(`Received eligible inline query, will call handler`);

        (async () => {
            if (TelegramHandlers[command_name]?.handler) {
                return TelegramHandlers[command_name].handler(parsed_context, this);
            }
            const common_command_index = commands.indexOf(command_name);
            if (common_command_index >= 0) {
                return getLegacyResponse(parsed_context, handlers[common_command_index], definitions[common_command_index]);
            }
        })().then(([err, response, _, overrides]) => {
            if (err == 'skip') {
                this.logger.debug(`Handler for [${command_name}] from inline query responded with skip`);
                return;
            }
            else if (err) {
                this.logger.debug(`Handler for [${command_name}] from inline query responded with error`, { error: err.stack || err });
                return;
            }
            if (response) {
                try {
                    if (response instanceof String || typeof response === 'string') {
                        return this._answerQuery([this._generateInlineText(
                            response,
                            overrides
                        )]).catch(err =>
                            this.logger.error(`Error while responsing to inline query with text`, { error: err.stack || err })
                        );
                    }
                    else if (Array.isArray(response)) {
                        return this._answerQuery(response.map(r => this._generateInlineMedia(r, overrides))).catch(err => 
                            this.logger.error('Error while responding to inline query with array', { error: err.stack || err })
                        );
                    }
                    else if (response instanceof Object) {
                        return this._answerQuery([this._generateInlineMedia(
                            response,
                            overrides
                        )]).catch(err =>
                            this.logger.error(`Error while responding to inline query with media`, { error: err.stack || err })
                        );
                    }
                }
                catch (err) {
                    this.logger.error(`Error when generating inline query`, { error: err.stack || err });
                }
            }
        }).catch(err => {
            this.logger.error(`Error while processing inline query`, { error: err.stack || err });
        });
    }
}

class TelegramClient {
    /**
     * TelegramClient
     * @param {Object} app containing logger and redis
     */
    constructor(app) {
        this.app = app;
        this.redis = app.redis ? app.redis : null;
        this.log_meta = { module: 'telegram-client' };
        this.logger = require('../logger').child(this.log_meta);
        this.inline_commands = [];
        this.registered_commands = new Map();
        this.callbacks = {};
    }

    /**
     * 
     * @param {String} command_name command name
     * @param {* | Function?} condition {false} condition on which to register command or function that returns this condition
     * @param {Boolean?} is_inline {false} if command should be available for inline querying
     * @param {String?} handle_function_name {command_name} which function from TelegramHandler handles this command
     */
    _registerTelegramCommand(command_name, condition = false, is_inline = false, handle_function_name = command_name) {
        if (typeof condition === 'function') {
            if (!condition()) return;
        }
        else if (!condition)  return;

        TelegramHandlers[handle_function_name]?.help && this.registered_commands.set(command_name, TelegramHandlers[handle_function_name].help);

        this.client.command(command_name, async (ctx) => new TelegramInteraction(this, handle_function_name, ctx).reply());
        if (is_inline) {
            this.inline_commands.push(command_name);
        }
    }

    _filterServiceMessages() {
        this.client.on('message:pinned_message', async (ctx) => {
            if (ctx.message?.pinned_message?.from?.is_bot) {
                ctx.deleteMessage().catch((err) => {
                    this.logger.error(`Error while deleting service [message: ${ctx.message.message_id}] in [chat: ${ctx.chat.id}] `, { error: err.stack || err });
                });
            }
        });
    }

    _registerCommands() {
        // Registering commands specific to Telegram
        this._registerTelegramCommand('start', true);
        this._registerTelegramCommand('help', true, true);
        this._registerTelegramCommand('info', true);
        this._registerTelegramCommand('html', true, true);
        this._registerTelegramCommand('fizzbuzz', true, true);
        this._registerTelegramCommand('gh', true, true);
        this._registerTelegramCommand('set', this.app && this.app.redis);
        this._registerTelegramCommand('get', this.app && this.app.redis, true);
        this._registerTelegramCommand('get_list', this.app && this.app.redis, true);
        this._registerTelegramCommand('del', this.app && this.app.redis);
        this._registerTelegramCommand('webapp', process.env.WEBAPP_URL);
        this._registerTelegramCommand('roundit', true);
        this._registerTelegramCommand('deep', config.DEEP_AI_API && process.env.DEEP_AI_TOKEN);
        this._registerTelegramCommand('voice', true);
        this._registerTelegramCommand('answer', process.env.OPENAI_TOKEN || process.env.ANTHROPIC_TOKEN);
        // this._registerTelegramCommand('tree', process.env.OPENAI_TOKEN);
        this._registerTelegramCommand('autoreply', process.env.OPENAI_TOKEN || process.env.ANTHROPIC_TOKEN);
        this._registerTelegramCommand('autoreply_on', process.env.OPENAI_TOKEN || process.env.ANTHROPIC_TOKEN);
        this._registerTelegramCommand('autoreply_off', process.env.OPENAI_TOKEN || process.env.ANTHROPIC_TOKEN);
        this._registerTelegramCommand('new_system_prompt', process.env.OPENAI_TOKEN || process.env.ANTHROPIC_TOKEN);
        this._registerTelegramCommand('context', process.env.OPENAI_TOKEN || process.env.ANTHROPIC_TOKEN);
        this._registerTelegramCommand('gpt4', process.env.OPENAI_TOKEN);
        this._registerTelegramCommand('opus', process.env.ANTHROPIC_TOKEN);
        this._registerTelegramCommand('sonnet', process.env.ANTHROPIC_TOKEN);
        this._registerTelegramCommand('vision', process.env.OPENAI_TOKEN);
        this._registerTelegramCommand('tldr', process.env.YA300_TOKEN && config.YA300_API_BASE, true);
        this._registerTelegramCommand('t', this.app && this.app.redis, true);
        this._registerTelegramCommand('set_sticker');
        this._registerTelegramCommand('c', process.env.WEBAPP_URL, true);
        this._registerTelegramCommand('events', process.env.DISCORD_TOKEN && process.env.REDIS_URL);
        
        // Registering common commands
        commands.forEach((command_name, index) => {
            if (typeof conditions[index] === 'function') {
                if (!conditions[index]()) return;
            }
            else if (!conditions[index]) return;

            let args = [];
            if (definitions?.[index]?.args?.length) {
                for (const arg of definitions[index].args) {
                    args.push(`{${arg.name}${arg.optional ? '?' : ''}}`);
                }
            }
            this.registered_commands.set(command_name, [args.join(' '), definitions[index].description]);

            this.client.command(command_name, async (ctx) => handleCommand(ctx, handlers[index], definitions[index]));
            if (definitions[index].is_inline) {
                this.inline_commands.push(command_name);
            }
            if (callbacks[index] != null) {
                this.callbacks[command_name] = callbacks[index];
            }
        });

        this.client.on('inline_query', async (ctx) => new TelegramInteraction(this, 'inline_query', ctx).answer());

        this.client.on('chosen_inline_result', (ctx) => {
            if (ctx.chosenInlineResult?.result_id?.startsWith('tinkov:')) {
                tinkovUsed(ctx.chosenInlineResult.result_id);
            }
        });
    }

    async _publishCommands() {
        return this.client.api.setMyCommands(
            [...this.registered_commands.entries()]
                .reduce((acc, [command_name, help]) => {
                    if (help?.length) {
                        acc.push({command: command_name, description: help.join(' ')});
                    }
                    return acc;
                }, []),
            {
                scope: {
                    type: 'default'
                }
            }
        ).catch(err => {
            this.logger.error('Error while registering commands', { error: err.stack || err });
        }).then(registered => {
            if (registered) this.logger.debug('Received successful response for commands registering');
            return this.client.api.getMyCommands({ scope: { type: 'default' } });
        }).catch(err => {
            this.logger.error('Error while getting registered commands', { error: err.stack || err });
        }).then(commands => {
            this.logger.debug(`Received following registered commands: ${JSON.stringify(commands)}`);
        });
    }

    async _saveInterruptedWebhookURL() {
        try {
            const { url } = await this.client.api.getWebhookInfo();

            if (url) {
                this.logger.info(`Saving interrupted webhook url for restoration [${url}]`);
                this._interruptedWebhookURL = url;
            }
        }
        catch (err) {
            this.logger.error('Got an error, while getting Webhook Info', { error: err.stack || err });
        }
    }

    async _startPolling() {
        if (!process.env.TELEGRAM_TOKEN) {
            this.logger.warn(`Token for Telegram wasn't specified, client is not started.`);
            return;
        }

        await this._saveInterruptedWebhookURL();

        return this.client.start({
            onStart: () => {
                this.logger.info('Long polling is starting');
                setHealth('telegram', 'ready');
            }
        }).then(() => {
            this.logger.info('Long polling has ended');
            setHealth('telegram', 'off');
        }).catch(err => {
            this.logger.error(`Error while starting Telegram client`, { error: err.stack || err });
            setHealth('telegram', 'off');
        });
    }

    async _setWebhook(webhookUrl) {
        if (!webhookUrl) {
            webhookUrl = `${process.env.DOMAIN}/telegram-${Date.now()}`;
        }

        try {
            await this.client.api.setWebhook(webhookUrl);

            if (this._interruptedWebhookURL) {
                this.logger.info(`Restored interrupted webhook url [${this._interruptedWebhookURL}]`);
            }
            else {
                this.logger.info('Telegram webhook is set.');
                setHealth('telegram', 'ready');
                this.app.api_server.setWebhookMiddleware(`/${webhookUrl.split('/').slice(-1)[0]}`, webhookCallback(this.client, 'express'));
            }
        }
        catch (err) {
            this.logger.error(`Error while setting telegram webhook`, { error: err.stack || err });
            this.logger.info('Trying to start with polling');
            return this._startPolling();
        }
    }

    _registerGPTAnswers() {
        if (!process.env.ANTHROPIC_TOKEN) {
            return;
        }

        /* Sesitive data
        * this.client.command('context', async (ctx) => {
        *     if (ctx?.message?.reply_to_message) {
        *         this.chatgpt_handler.handleContextRequest(new TelegramInteraction(this.client, 'context', ctx));
        *     }
        * });
        */

        this.client.on('message', async (ctx) => {
            if (ctx?.message?.reply_to_message?.from?.id === this.client.botInfo.id
                && (isChannelNotificationMessage(ctx?.chat?.id, ctx?.message?.reply_to_message?.message_id)
                || isEventNotificationMessage(ctx?.chat?.id, ctx?.message?.reply_to_message?.message_id))
                ) {
                    return;
            }
            if (!ctx?.from?.is_bot && ctx?.message?.reply_to_message?.from?.id === this.client.botInfo.id) {
                ChatLLMHandler.answerReply(new TelegramInteraction(this, null, ctx));
            }
            else if (ctx.chat.id === ctx.from.id) {
                ChatLLMHandler.answerQuestion(new TelegramInteraction(this, null, ctx));
            }
        });

    }

    _registerCallbacks() {
        this.client.on('callback_query:data', async (ctx) => {
            const prefix = ctx.callbackQuery.data.split(':')[0];
            if (this.callbacks[prefix] != null) {
                return handleCallback(ctx, this.callbacks[prefix]);
            }
        });
    }

    _registerBanHammer() {
        this.client.on(':new_chat_members', async (ctx) => {
            if (ctx.chat?.id !== -1001842693349) return;
            const until_date = Date.now() + (5 * 60 * 1000);
            for (const user of ctx.message.new_chat_members) {
                ctx.banChatMember(user.id, { until_date, revoke_messages: false }).catch((err) => this.logger.error(`Couldn't ban chat member [${user.username || user.first_name || user.id}] in [${ctx.chat.username || ctx.chat.title || ctx.chat.id}]`, { error: err.stack || err }));
            }
        })
    }

    start() {
        if (!process.env.TELEGRAM_TOKEN) {
            this.logger.warn(`Token for Telegram wasn't specified, client is not started.`);
            return;
        }
        setHealth('telegram', 'wait');

        this.client = new Bot(process.env.TELEGRAM_TOKEN, {
            client: {
                buildUrl: (root, token, method) => `${root}/bot${token}${process.env?.ENV === 'dev' ? '/test' : ''}/${method}`
            }
        });

        this.client.catch((err) => {
            this.logger.error(`High level middleware error in bot`, { error: err.stack || err });
        });

        // plugins
        this.client.api.config.use(hydrateFiles(process.env.TELEGRAM_TOKEN, {
            buildFileUrl: (root, token, path) => `${root}/file/bot${token}${process.env?.ENV === 'dev' ? '/test' : ''}/${path}`
        }));

        // filters
        this._filterServiceMessages();

        // handlers
        this._registerCommands();
        this._registerGPTAnswers();
        this._registerCallbacks();

        // autoban
        this._registerBanHammer();
        this._publishCommands();
        if (process.env.ENV?.toLowerCase() === 'dev' || !process.env.PORT || !process.env.DOMAIN) {
            this._startPolling();
        }
        else {
            this._setWebhook();
        }
    }

    async stop() {
        if (!process.env.TELEGRAM_TOKEN) {
            return;
        }
        this.logger.info('Gracefully shutdowning Telegram client.');

        await this.client.api.deleteWebhook();
        await this.client.stop();
        if (this._interruptedWebhookURL) {
            await this._setWebhook(this._interruptedWebhookURL); // restoring interrupted webhook if possible
        }
        setHealth('telegram', 'off');
    }
}

module.exports = {
    TelegramClient,
    TelegramInteraction
};