Предыстория

Несколько месяцев назад как-то больше по приколу написал телеграм-бота с интеграцией GPT. Это было, кстати, ещё до того, как весь телеграм утонул в этих ботах. После этого решил, что можно попробовать эту область на фрилансе. За эти месяцы сделал миллион всяких телеграм-ботов с GPT, другими нейронками с доступным API (и даже недоступным в случае с Midjourney), всякие магазины и тому прочих ботов. Этот опыт позволил прошариться немного за телеграм-ботов и в этом материале расскажу об основных моментах, с которыми Вы скорее всего столкнётесь при написании телеграм-ботов на NodeJS. Если есть чем меня дополнить или, возможно, поправить, то буду рад обратной связи.

Начало работы

Для начала создадим NodeJS проект и установим туда пакет для работы с телеграм-ботом через npm:

npm init
npm i node-telegram-bot-api

И инициализируем библиотеку для работы с телеграм-ботом в проект:

const TelegramBot = require('node-telegram-bot-api');

Далее нам нужно создать экземпляр класса TelegramBot. В конструктор нам необходимо передать токен нашего бота (создать бота и получить токен для него можно в BotFather):

const bot = new TelegramBot(process.env.API_KEY_BOT, {

    polling: true
    
});

Я для хранения таких переменных, как токен бота, использую модуль dotenv, однако, это можно представить в следующем виде:

const API_KEY_BOT = 'Токен от Вашего бота';

const bot = new TelegramBot(API_KEY_BOT, {

    polling: true
    
});

Polling

Обратите внимание, что вместе с токеном бота я передаю объект, в котором включаю polling - это клиент-серверная технология, которая позволяет нам получать обновления с серверов телеграма. Если пользователь что-то написал боту, мы должны об этом как-то узнать и для этого мы будем с определенной периодичностью опрашивать сервер на предмет наличия новых действий пользователя с ботом. Polling можно просто включить указав ему значение true, но его можно настроить передав в значение объект с настройками, например:

const API_KEY_BOT = 'Токен от Вашего бота';

const bot = new TelegramBot(API_KEY_BOT, {

  polling: {
    interval: 300,
    autoStart: true
  }

});

В данном примере я установил интервал между запросами с клиента на сервер в миллисекундах. autoStart отвечает за то, что наш бот отвечает на те сообщения, которые он пропустил за то время, когда был выключен. Однако, у polling есть ещё настройки, например, можно передать в значение params объект с параметрами такими, как timeout.

Также давайте добавим наш первый слушатель боту - обработаем ошибку polling'а, выведем в консоль сообщение ошибки, если она вообще будет:

bot.on("polling_error", err => console.log(err.data.error.message));

Стоит отметить, что получать наш бот информацию о действиях с ним может не только с помощью технологии polling, но и с помощью webhook.

Обработка и отправка текстового сообщения

Далее обработаем сообщения от пользователей. Для этого сначала добавим слушатель текстового сообщения:

bot.on('text', async msg => {

    console.log(msg);

})

В данном примере мы добавили слушатель типа 'text'. Он возвращает нам следующий объект:

{
  message_id: ID_СООБЩЕНИЯ,
  from: {
    id: ID_ПОЛЬЗОВАТЕЛЯ,
    is_bot: false,
    first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
    username: НИК_ПОЛЬЗОВАТЕЛЯ,
    language_code: 'ru'
  },
  chat: {
    id: ID_ЧАТА,
    first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
    username: НИК_ПОЛЬЗОВАТЕЛЯ,
    type: 'private'
  },
  date: 1686255759,
  text: ТЕКСТ_СООБЩЕНИЯ,
}

Данный объект мы получаем в переменную msg и выводим в консоль. Расскажу немного подробнее про то, какие данные возвращает этот объект и на что стоит обратить внимание. В переменной text содержится само сообщение, которое нам написал пользователь. message_id определяет id сообщения, благодаря чему мы сможем далее обратиться к этому сообщению в обработчике слушателя, а объекты from и chat содержат информацию о том, какой пользователь написал это сообщение и в каком чате - чаще всего, например, id чата и id пользователя будут совпадать, однако, пользователи могут добавить бота в чат и писать ему туда - это тоже стоит учесть. Также бывают случаи, когда имя и ник пользователя могут до нас не дойти через слушатель, например, если пользователь закрыл это дело настройками конфиденциальности в телеграме - это тоже стоит учесть, например, в случае если мы хотим как-то обращаться к пользователю по нику или имени от лица бота.

Теперь мы принимаем какую-то информацию, когда пользователь пишет сообщение боту. Давайте с помощью этой информации построим примитивного эхо-бота - наш бот будет отвечать тем же текстом, что и написал нам пользователь. Для этого используем метод sendMessage, в который передадим id чата, в который мы будем отправлять сообщение, и содержание этого сообщения:

bot.on('text', async msg => {

    await bot.sendMessage(msg.chat.id, msg.text);

})

Вот так это дело работает:

Эхо-бот отвечает
Эхо-бот отвечает

Давайте немного усложним нашу конструкцию. Практически это смысла иметь не будет, но позволит нам разобрать ещё несколько методов для работы с ботом. Попробуем сначала выводить пользователю сообщение о том, что бот генерирует ответ на сообщение, а затем через 5 секунд бот будет удалять сообщение о генерации ответа и будет скидывать ответ, в нашем случае то же сообщение, что и скинул нам пользователь:

bot.on('text', async msg => {

    const msgWait = await bot.sendMessage(msg.chat.id, `Бот генерирует ответ...`);

    setTimeout(async () => {

        await bot.deleteMessage(msgWait.chat.id, msgWait.message_id);
        await bot.sendMessage(msg.chat.id, msg.text);

    }, 5000);

})

В данном примере, мы скидываем пользователю сообщение о генерации и записываем ответ сервера на запрос отправки сообщения в переменную msgWait. Ответ сервера будет объектом того же вида, что и объект msg. Далее, через пять секунд, используем метод deleteMessage для удаления сообщения о генерации и скидываем сам ответ.

Бот отвечает сообщением о генерации ответа
Бот отвечает сообщением о генерации ответа
Прошло 5 секунд и бот удалил сообщение о генерации, и ответил тем же текстом
Прошло 5 секунд и бот удалил сообщение о генерации, и ответил тем же текстом

Попробуем немного изменить это дело. Вместо удаления сообщения и отправки нового сделаем отправку сообщения о генерации, а затем через 5 секунд отредактируем это сообщение на наш ответ:

bot.on('text', async msg => {

    const msgWait = await bot.sendMessage(msg.chat.id, `Бот генерирует ответ...`);

    setTimeout(async () => {

        await bot.editMessageText(msg.text, {

            chat_id: msgWait.chat.id,
            message_id: msgWait.message_id

        });

    }, 5000);

})

Для этого мы используем метод editMessageText, в который передаём строку на которую изменим наше сообщение, а также объект с данными о том, в каком чате и какое именно сообщение надо изменить.

Также хочу обратить Ваше внимание на то, что по-хорошему необходимо все наши методы обернуть в конструкцию try/catch, потому что возможен вариант, при котором наш код отлично отрабатывает, всё хорошо написано, однако, пользователь заблокировал бота, и если бот попытается ему написать сообщение, то сервер нам вернёт ошибку и весь бот может крашнуться.

bot.on('text', async msg => {

    try {

        await bot.sendMessage(msg.chat.id, msg.text);

    }
    catch(error) {

        console.log(error);

    }

})

Обрабатываем запуск бота

Далее поговорим про обработку запуска бота. Каждый раз, когда новый пользователь заходит в бота перед ним появляется кнопка "Запустить", вот так это выглядит:

Кнопка запуска бота
Кнопка запуска бота

Нажав эту кнопку пользователь отправит боту текстовую команду "/start". Сразу встаёт вопрос о том, как обработать это дело, чтобы поприветствовать нового пользователя и возможно записать какую-то информацию о пользователе в базу данных. Конечно, это можно сделать банальным способом и в слушателе текстового сообщения проверять, является ли сообщение пользователя текстовой командой "/start". Выглядеть это будет так:

bot.on('text', async msg => {

    try {

        if(msg.text == '/start') {

            await bot.sendMessage(msg.chat.id, `Вы запустили бота!`);

        }
        else {

            await bot.sendMessage(msg.chat.id, msg.text);

        }

    }
    catch(error) {

        console.log(error);

    }

})

Однако, у этого способа есть один минус, с которым лично я столкнулся. В ссылку на запуск бота мы можем передавать параметры и потом читать их. Это полезно, если мы, допустим, захотим сделать реферальную систему. Давайте я покажу, как это будет выглядеть, и в чем проблема. Для этого сделаем обработчик ещё одной команды, назовём её "/ref" и будем по этой команде отдавать пользователю уникальную ссылку на запуск бота:

bot.on('text', async msg => {

    try {

        if(msg.text == '/start') {
            
            await bot.sendMessage(msg.chat.id, `Вы запустили бота!`);

        }
        else if(msg.text == '/ref') {

            await bot.sendMessage(msg.chat.id, `${process.env.URL_TO_BOT}?start=${msg.from.id}`);

        }
        else {

            await bot.sendMessage(msg.chat.id, msg.text);

        }

    }
    catch(error) {

        console.log(error);

    }

})

Обратите внимание, для того чтобы передать какую-то информацию при запуске бота, я передаю в ссылку на запуск бота get-параметр с id пользователя, который получил эту ссылку.

Теперь, когда если мы перейдем по ссылке, у нас будет та же самая кнопка "Запустить", которая отправит "/start" боту. Однако, пользователь видит, что он отправил команду "/start", но нам в боте возвращается уже немного другой объект, выглядеть он будет так:

{
  message_id: ID_СООБЩЕНИЯ,
  from: {
    id: ID_ПОЛЬЗОВАТЕЛЯ,
    is_bot: false,
    first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
    username: НИК_ПОЛЬЗОВАТЕЛЯ,
    language_code: 'ru'
  },
  chat: {
    id: ID_ЧАТА,
    first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
    username: НИК_ПОЛЬЗОВАТЕЛЯ,
    type: 'private'
  },
  date: ДАТА,
  text: '/start ID_ПОЛЬЗОВАТЕЛЯ_ИЗ_РЕФЕРАЛЬНОЙ_ССЫЛКИ',
  entities: [ { offset: 0, length: 6, type: 'bot_command' } ]
}

В текст сообщения нам возвращается уже не просто "/start", а вместе с ним то, что мы передали в get-параметр в ссылке на запуск. Следовательно, нам надо кое-что изменить в обработчике команды "/start".

bot.on('text', async msg => {

    try {

        if(msg.text.startsWith('/start')) {
            
            await bot.sendMessage(msg.chat.id, `Вы запустили бота!`);

            if(msg.text.length > 6) {

                const refID = msg.text.slice(7);

                await bot.sendMessage(msg.chat.id, `Вы зашли по ссылке пользователя с ID ${refID}`);

            }

        }
        else if(msg.text == '/ref') {

            await bot.sendMessage(msg.chat.id, `${process.env.URL_TO_BOT}?start=${msg.from.id}`);

        }
        else {

            await bot.sendMessage(msg.chat.id, msg.text);

        }

    }
    catch(error) {

        console.log(error);

    }

})

Как видите, сначала мы изменили проверку с равенства текста сообщения команде "/start", на проверку, начинается ли текст сообщения с команды "/start". Затем проверяем есть ли ещё какие-то параметры в команде запуска, проверяя длину сообщения (6 в данном случае это длина строки "/start"), а затем вырезаем из текста сообщения команду "/start" вместе с пробелом после неё методом slice и записываем то, что мы передаём в ссылке на запуск бота в переменную.

Сделать это можно и другим способом, используя слушатель onText и регулярные выражения:

bot.onText(/\/start/, async msg => {

    try {

        await bot.sendMessage(msg.chat.id, `Вы запустили бота!`);

        if(msg.text.length > 6) {

            const refID = msg.text.slice(7);

            await bot.sendMessage(msg.chat.id, `Вы зашли по ссылке пользователя с ID ${refID}`);

        }

    }
    catch(error) {

        console.log(error);

    }

})

Слушатель принимает регулярное выражение, по которому будет проверять сообщение. Однако, если Вы будете применять данный способ, учтите, что если пользователь запустит бота, то сработает и слушатель onText с регулярным выражением, и слушатель on с типом 'text'.

Меню команд для бота

Поговорим о том, как создать меню команду для бота. Выглядит меню команд следующим образом:

Меню команд бота
Меню команд бота

Создать это меню можно в BotFather или с помощью метода setMyCommands. Второй способ мне кажется удобнее и быстрее. В этот метод нам нужно передать массив объектов, в которых указаны сами команды и их описания в меню.

const commands = [

    {

        command: "start",
        description: "Запуск бота"

    },
    {

        command: "ref",
        description: "Получить реферальную ссылку"

    },
    {

        command: "help",
        description: "Раздел помощи"

    },

]

bot.setMyCommands(commands);

Сначала мы задаём массив объектов с нашими командами, а затем передаём его в метод setMyCommands. Я создал команды, которые мы уже использовали до этого, а также создал новую команду help, давайте обработаем её в слушателе:

bot.on('text', async msg => {

    try {

        if(msg.text.startsWith('/start')) {
            
            await bot.sendMessage(msg.chat.id, `Вы запустили бота!`);

            if(msg.text.length > 6) {

                const refID = msg.text.slice(7);

                await bot.sendMessage(msg.chat.id, `Вы зашли по ссылке пользователя с ID ${refID}`);

            }

        }
        else if(msg.text == '/ref') {

            await bot.sendMessage(msg.chat.id, `${process.env.URL_TO_BOT}?start=${msg.from.id}`);

        }
        else if(msg.text == '/help') {

            await bot.sendMessage(msg.chat.id, `Раздел помощи`);

        }
        else {

            await bot.sendMessage(msg.chat.id, msg.text);

        }

    }
    catch(error) {

        console.log(error);

    }

})

Форматирование текста

Далее разберемся в форматировании текста в сообщениях. Разберем на примере обработки команды "/help". Для форматирования, стилизации, текста можно использовать либо HTML-верстку, либо Markdown-верстку. Для этого необходимо передавать в строку сообщения текст с тегами, а также передать объект с параметром parse_mode в метод sendMessage. Выглядеть это будет примерно так:

else if(msg.text == '/help') {

    await bot.sendMessage(msg.chat.id, `Раздел помощи HTML\n\n<b>Жирный Текст</b>\n<i>Текст Курсивом</i>\n<code>Текст с Копированием</code>\n<s>Перечеркнутый текст</s>\n<u>Подчеркнутый текст</u>\n<pre language='c++'>код на c++</pre>\n<a href='t.me'>Гиперссылка</a>`, {

        parse_mode: "HTML"

    });

    await bot.sendMessage(msg.chat.id, 'Раздел помощи Markdown\n\n*Жирный Текст*\n_Текст Курсивом_\n`Текст с Копированием`\n~Перечеркнутый текст~\n``` код ```\n||скрытый текст||\n[Гиперссылка](t.me)', {

        parse_mode: "MarkdownV2"

    });

}

Тогда команда "help" будет выводить нам следующее:

Стилизация текста
Стилизация текста

Вот список тегов с помощью которых Вы можете стилизовать текст в телеграм-ботах:

HTML:

  • <b> Текст </b> - Жирный текст

  • <i> Текст </i> - Текст курсивом

  • <code> Текст </code> - Текст, который можно скопировать нажатием на него

  • <s> Текст </s> - Перечеркнутый текст

  • <u> Текст </u> - Подчеркнутый текст

  • <pre language='язык'> Текст </pre> - Текст с оформлением кода

  • <a href='ссылка'> Текст </a> - Текст-гиперссылка

Markdown:

  • *Текст* - Жирный текст

  • _Текст_ - Текст курсивом

  • `Текст` - Текст, который можно скопировать нажатием на него

  • ~Текст~ - Перечеркнутый текст

  • ``` Текст ``` - Текст с оформлением кода

  • || Текст || - Скрытый текст

  • [Текст](Ссылка) - Текст-гиперссылка

Стоит отметить, что все теги должны быть обязательно закрыты, иначе бот не отправит сообщение и вернёт ошибку. В этом плане, я советую делать стилизацию именно с помощью HTML-тегов, так как могут быть проблемы, если вы делаете админку или взаимодействие пользователей со стилизованным текстом. Например, если закинуть ссылку обычным текстом, а не гиперссылкой в сообщение, у которого parse_mode стоит на Markdown, то все нижние подчеркивания будут именно тегами, не отобразятся пользователю, и если их нечетное количество, то сообщение вообще не отправится. Также обратите внимание на то, что переход на следующую строку выполняется при помощи "\n" и в стилизации HTML, и в стилизации Markdown, здесь нельзя использовать тег <br> из HTML.

Также если мы хотим вставить эмодзи в наше сообщение, то можно просто скопировать эмодзи из телеграмма и вставить в нашу строку в коде, например:

await bot.sendMessage(msg.chat.id, `Вы запустили бота! ????????`);
Эмодзи в сообщении
Эмодзи в сообщении

Также ещё подробнее поговорим про ссылки. Если мы укажем ссылку в сообщении, то сообщение придёт пользователю с превью ссылки. Добавлю для этого команду "/link" и её обработчик. Выглядит это так:

else if(msg.text == '/link') {

    await bot.sendMessage(msg.chat.id, `https://habr.com/`);

}
Превью ссылки в сообщении
Превью ссылки в сообщении

Если мы хотим убрать превью в сообщении, то нам необходимо передать в метод sendMessage объект с параметром disable_web_page_preview со значением true:

else if(msg.text == '/link') {

    await bot.sendMessage(msg.chat.id, `https://habr.com/`, {

        disable_web_page_preview: true,

    });

}
Сообщение без превью
Сообщение без превью
Отправить без звука
Отправить без звука

Кстати, в этот же объект, помимо parse_mode и disable_web_page_preview, мы можем передать параметр disable_notification - это позволит отправить сообщение пользователю без уведомления:

else if(msg.text == '/link') {

    await bot.sendMessage(msg.chat.id, `https://habr.com/`, {

        disable_web_page_preview: true,
        disable_notification: true

    });

}

Меню-клавиатура

Разные меню в боте
Разные меню в боте

Далее обсудим меню-клавиатуру. Меню-клавиатуры делятся на два типа: то меню, которое находится рядом с вводом текста, и меню, которое привязано к сообщению. Между ними есть некоторая разница, о которой мы поговорим дальше. Давайте для начала попробуем создать меню, которое не привязано к сообщению - для удобства я буду его дальше называть просто клавиатура. Для этого создадим ещё одну команду "menu" и обработаем её:

else if(msg.text == '/menu') {

    await bot.sendMessage(msg.chat.id, `Меню бота`, {

        reply_markup: {

            keyboard: [

                ['⭐️ Картинка', '⭐️ Видео'],
                ['⭐️ Аудио', '⭐️ Голосовое сообщение']

            ]

        }

    })

}

В объект, в который мы раньше передавали параметры disable_web_page_preview, disable_notification, parse_mode теперь передаём reply_markup, который содержит массив массивов keyboard. Обратите внимание, что каждый массив в keyboard - это отдельная строка сверху вниз. То есть мы задаём массивами строки меню, а внутри строк задаём сами кнопки с помощью строк. Выглядеть то, что написано выше, в боте будет так:

Меню-клавиатура
Меню-клавиатура

Можем заметить, что кнопки получились какими-то большими. Для того, чтобы задать им адекватные размеры, можно в объект reply_markup передать параметр resize_keyboard со значением true. Давайте так и сделаем, и добавим в меню ещё несколько кнопок:

else if(msg.text == '/menu') {

    await bot.sendMessage(msg.chat.id, `Меню бота`, {

        reply_markup: {

            keyboard: [

                ['⭐️ Картинка', '⭐️ Видео'],
                ['⭐️ Аудио', '⭐️ Голосовое сообщение'],
                ['⭐️ Контакт', '⭐️ Геолокация'],
                ['❌ Закрыть меню']

            ],
            resize_keyboard: true

        }

    })

}
Меню-клавиатура с параметром resize_keyboard
Меню-клавиатура с параметром resize_keyboard

Далее встаёт вопрос о том, как обработать нажатие кнопки в нашей клавиатуре. Тут всё на самом деле просто. Когда пользователь нажимает на кнопку в меню, он скидывает боту текстовое сообщение с тем текстом, который написан на кнопке, который мы указывали в строках внутри массивов. Следовательно, будем обрабатывать нажатие на кнопку, как обычное текстовое сообщение:

else if(msg.text == '❌ Закрыть меню') {

    await bot.sendMessage(msg.chat.id, 'Меню закрыто', {

        reply_markup: {

            remove_keyboard: true

        }

    })

}

В данном примере, мы обработали нажатие кнопки закрытия меню. Обратите внимание на то, что если Вы просто отправите сообщение пользователю, клавиатура у него никуда не пропадёт. Для того, чтобы выключить клавиатуру, передаём параметр remove_keyboard со значением true в reply_markup. Как видим, клавиатура у нас пропала:

Выключение клавиатуры
Выключение клавиатуры

О том, как создать меню привязанное к сообщению поговорим чуть дальше.

Скидываем и обрабатываем изображение

Сейчас поговорим о том, как скинуть пользователю картинку, и как обработать сообщение с картинкой от пользователя.

Давайте будем скидывать картинку пользователю по нажатию кнопки "⭐️ Картинка" в меню. Саму кнопку мы уже создали, давайте обработаем её нажатие и с помощью метода sendPhoto, в который передадим ссылку на изображение, которое хотим скинуть пользователю:

else if(msg.text == '⭐️ Картинка') {

    await bot.sendPhoto(msg.chat.id, process.env.URL_TO_IMG);

}

В данном случае, мы скидываем пользователю картинку с помощью ссылки на изображение, однако, мы можем скинуть картинку и просто указав путь до картинки:

else if(msg.text == '⭐️ Картинка') {

    await bot.sendPhoto(msg.chat.id, './image.jpg');

}

Также можно скинуть картинку используя модуль fs:

else if(msg.text == '⭐️ Картинка') {

    //Скидываем изображение ссылкой
    await bot.sendPhoto(msg.chat.id, process.env.URL_TO_IMG);
    //Скидываем изображение указав путь
    await bot.sendPhoto(msg.chat.id, './image.jpg');
    //Скидываем изображение с помощью Readable Stream
    const imageStream = fs.createReadStream('./image.jpg');
    await bot.sendPhoto(msg.chat.id, imageStream);
    //Скидываем изображение с помощью буфера
    const imageBuffer = fs.readFileSync('./image.jpg');
    await bot.sendPhoto(msg.chat.id, imageBuffer);

}

Теперь наш бот будет вести себя примерно так:

Бот скидывает картинку
Бот скидывает картинку

Бот скидывает только картинку. Однако, нам может понадобится добавить какую-то подпись к этой картинке. Для этого передаем в метод sendPhoto объект с опциями, надпись можно передать в параметр caption, также можем задать parse_mode, как и в обычном текстовом сообщении:

const imageStream = fs.createReadStream('./image.jpg');
await bot.sendPhoto(msg.chat.id, imageStream, {

    caption: '<b>⭐️ Картинка</b>',
    parse_mode: 'HTML'

});

Далее разберемся с тем, как обработать сообщение с изображением от пользователя. Для этого используем слушатель с типом "photo":

bot.on('photo', async img => {

    console.log(img);

})

Теперь, когда пользователь скинет сообщение с картинкой, телеграм вернёт нам объект следующего вида:

{
  message_id: 500,
  from: {
    id: ID_ПОЛЬЗОВАТЕЛЯ,
    is_bot: false,
    first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
    username: НИК_ПОЛЬЗОВАТЕЛЯ,
    language_code: 'ru'
  },
  chat: {
    id: ID_ЧАТА,
    first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
    username: НИК_ПОЛЬЗОВАТЕЛЯ,
    type: 'private'
  },
  date: ДАТА,
  photo: [
    {
      file_id: ID_ФАЙЛА,
      file_unique_id: УНИКАЛЬНЫЙ_ID_ФАЙЛА,
      file_size: РАЗМЕР_ФАЙЛА,
      width: ШИРИНА_КАРТИНКИ,
      height: ВЫСОТА_КАРТИНКИ
    },
    {
      file_id: ID_ФАЙЛА,
      file_unique_id: УНИКАЛЬНЫЙ_ID_ФАЙЛА,
      file_size: РАЗМЕР_ФАЙЛА,
      width: ШИРИНА_КАРТИНКИ,
      height: ВЫСОТА_КАРТИНКИ
    },
    {
      file_id: ID_ФАЙЛА,
      file_unique_id: УНИКАЛЬНЫЙ_ID_ФАЙЛА,
      file_size: РАЗМЕР_ФАЙЛА,
      width: ШИРИНА_КАРТИНКИ,
      height: ВЫСОТА_КАРТИНКИ
    },
    {
      file_id: ID_ФАЙЛА,
      file_unique_id: УНИКАЛЬНЫЙ_ID_ФАЙЛА,
      file_size: РАЗМЕР_ФАЙЛА,
      width: ШИРИНА_КАРТИНКИ,
      height: ВЫСОТА_КАРТИНКИ
    }
  ]
}

Мы похожее уже видели, когда обрабатывали текстовое сообщение. Только теперь мы имеем дело не со строкой text, а с массивом объектов photo. В этом массиве содержится наша картинка. Телеграм принял картинку, хранит у себя на сервере, и вернул нам несколько вариантов нашей картинки в разных размерах. Последний объект в этом массиве - это наш оригинал, а другие объекты содержат нашу картинку, только в сжатом виде. Но как же получить картинку, которую скидывал пользователь по информации содержащейся в объектах? Мы можем получить доступ к нашей картинке по file_id, который возвращает нам телеграм. Например, мы можем скачать файл по file_id с серверов телеграм используя метод downloadFile, в который передаём file_id, который нужно скачать, и директорию в которую будем скачивать файл. Выглядеть это будет так:

bot.on('photo', async img => {

    try {

        await bot.downloadFile(img.photo[img.photo.length-1].file_id, './image');

    }
    catch(error) {

        console.log(error);

    }

})

Обратите внимание, что для того, чтобы выбрать наш оригинал в индексе массива я указал длину массива минус один - не будем забывать, что мы, программисты, считаем с нуля.

Давайте теперь сделаем следующее: когда пользователь скидывает нам изображение, будем скидывать ему в ответ несколько изображений объединенных в одно сообщение. Эти несколько изображений - это наш оригинал и сжатые его варианты. Для этого воспользуемся методом sendMediaGroup:

bot.on('photo', async img => {

    try {

        const photoGroup = [];

        for(let index = 0; index < img.photo.length; index++) {

            const photoPath = await bot.downloadFile(img.photo[index].file_id, './image');

            photoGroup.push({

                type: 'photo',
                media: photoPath,
                caption: `Размер файла: ${img.photo[index].file_size} байт\nШирина: ${img.photo[index].width}\nВысота: ${img.photo[index].height}`

            })

        }

        await bot.sendMediaGroup(img.chat.id, photoGroup);

        for(let index = 0; index < photoGroup.length; index++) {

            fs.unlink(photoGroup[index].media, error => {

                if(error) {

                    console.log(error);

                }

            })

        }

    }
    catch(error) {

        console.log(error);

    }

})

Остановимся на этом поподробнее. В самом начале мы инициализируем массив. Он нам нужен для того, чтобы передать несколько картинок в метод sendMediaGroup и соответственно скинуть эти картинки пользователю. Метод sendMediaGroup принимает массив объектов, в которых мы указываем первым делом тип медиа-контента, в нашем случае это "photo". Также в объекте медиа-контента мы должны передать путь до нашего контента, этим путём могут служить, также как и в случае с методом sendPhoto: url, stream, buffer или путь до контента. Также мы можем указать file_id - его можно тоже указывать при отправке через sendPhoto или через sendMediaGroup, однако, в данном случае этот способ нам не подходит, так как какой бы file_id мы не указали, мы всегда будем скидывать оригинал картинки, а нас интересуют именно сжатые картинки. Дополнительно в объект медиа-контента мы можем передать caption - подпись под картинкой. В данном случае, я добавил в подпись под каждой картинкой её размер в байтах, ширину и высоту в пикселях - всё это нам возвращает телеграм. Теперь пройдясь циклом по всем вариантам нашей картинки, скачав все варианты и записав всю необходимую информацию, передаём наш массив объектов в метод sendMediaGroup, а затем удаляем все картинки, которые только что скачали. И теперь имеем следующее:

Бот скидывает все варианты сжатых картинок и оригинал
Бот скидывает все варианты сжатых картинок и оригинал
Подпись под изображением
Подпись под изображением

Скидываем и обрабатываем видео

Далее поговорим о том, как скинуть видео. Тут в общем-то всё аналогично фото, обработаем нажатие на кнопку на "⭐️ Видео" в нашем меню:

else if(msg.text == '⭐️ Видео') {

    await bot.sendVideo(msg.chat.id, './video.mp4');

}

Также мы туда можем добавить caption и parse_mode, и другие параметры, вроде disable_notification:

else if(msg.text == '⭐️ Видео') {

    await bot.sendVideo(msg.chat.id, './video.mp4', {

        caption: '<b>⭐️ Видео</b>',
        parse_mode: 'HTML'

    });

}

Получаем следующее:

Бот скидывает видео
Бот скидывает видео

Теперь давайте обработаем сообщение пользователя с видео. Делаем это также с помощью слушателя с типом "video":

bot.on("video", async video => {

    console.log(video);

})

Телеграм присылает нам следующее, когда пользователь отправляет сообщение с видео:

{
  message_id: ID_СООБЩЕНИЯ,
  from: {
    id: ID_СООБЩЕНИЯ,
    is_bot: false,
    first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
    username: НИК_ПОЛЬЗОВАТЕЛЯ,
    language_code: 'ru'
  },
  chat: {
    id: ID_ЧАТА,
    first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
    username: НИК_ПОЛЬЗОВАТЕЛЯ,
    type: 'private'
  },
  date: ДАТА,
  video: {
    duration: ДЛИТЕЛЬНОСТЬ_ВИДЕО,
    width: ВЫСОТА_ВИДЕО,
    height: ШИРИНА_ВИДЕО,
    file_name: ИМЯ_ФАЙЛА,
    mime_type: ТИП_ФАЙЛА,
    thumbnail: {
      file_id: ID_ИЗОБРАЖЕНИЯ,
      file_unique_id: УНИКАЛЬНОЕ_ID_ИЗОБРАЖЕНИЯ,
      file_size: РАЗМЕР_ИЗОБРАЖЕНИЯ,
      width: ШИРИНА_ИЗОБРАЖЕНИЯ,
      height: ВЫСОТА_ИЗОБРАЖЕНИЯ
    },
    thumb: {
      file_id: ID_ИЗОБРАЖЕНИЯ,
      file_unique_id: УНИКАЛЬНОЕ_ID_ИЗОБРАЖЕНИЯ,
      file_size: РАЗМЕР_ИЗОБРАЖЕНИЯ,
      width: ШИРИНА_ИЗОБРАЖЕНИЯ,
      height: ВЫСОТА_ИЗОБРАЖЕНИЯ
    },
    file_id: ID_ВИДЕО,
    file_unique_id: УНИКАЛЬНОЕ_ID_ВИДЕО,
    file_size: РАЗМЕР_ВИДЕО
  }
}

Как видим, телеграм предоставляет нам информацию о видео и изображениях. Изображения - это миниатюры видео. Давайте сделаем следующее: когда пользователь скидывает видео, бот будет в ответ кидать само видео, миниатюру и информацию о видео. Выглядеть это будет следующим образом:

bot.on("video", async video => {

    try {

        const thumbPath = await bot.downloadFile(video.video.thumbnail.file_id, './image');

        await bot.sendMediaGroup(video.chat.id, [
            
            {

                type: 'video',
                media: video.video.file_id,
                caption: `Название файла: ${video.video.file_name}\nВес файла: ${video.video.file_size} байт\nДлительность видео: ${video.video.duration} секунд\nШирина кадра в видео: ${video.video.width}\nВысота кадра в видео: ${video.video.height}`

            },
            {

                type: 'photo',
                media: thumbPath,

            }

        ]);

        fs.unlink(thumbPath, error => {

            if(error) {

                console.log(error);

            }

        })

    }
    catch(error) {

        console.log(error);

    }

})

Обратите внимание, что видео-файл я скидываю по его file_id, а миниатюру скачиваю, скидываю и затем удаляю, потому что телеграм не даёт скинуть миниатюру методом sendPhoto по file_id. Также важно, что в этот раз я добавил в методе sendMediaGroup параметр caption лишь к одному медиа-файлу и надпись отобразилась в сообщении.

Также есть важный момент, который нужно учесть. Иногда, когда пользователь скидывает видео, допустим в том же .mp4, телеграм может это сжать и скинуть в .gif.

Скидываем и обрабатываем аудио

Ситуация уже знакомая нам, скидываем аудио также, как видео и фото, обрабатывая кнопку "⭐️ Аудио":

else if(msg.text == '⭐️ Аудио') {

    await bot.sendAudio(msg.chat.id, './audio.mp3', {

        caption: '<b>⭐️ Аудио</b>',
        parse_mode: 'HTML'

    });

}

Обработка аудио выполняется тоже похожим образом, только используем слушатель с типом "audio":

bot.on('audio', async audio => {

    try {

        await bot.sendAudio(audio.chat.id, audio.audio.file_id, {

            caption: `Название файла: ${audio.audio.file_name}\nВес файла: ${audio.audio.file_size} байт\nДлительность аудио: ${audio.audio.duration} секунд`

        })

    }
    catch(error) {

        console.log(error);

    }

})
Бот отвечает на сообщение с аудио-файлом
Бот отвечает на сообщение с аудио-файлом

Телеграм-сервер возвращает похожий на предыдущие примеры объект:

{
  message_id: 653,
  from: {
    id: 764548588,
    is_bot: false,
    first_name: 'shavrin',
    username: 'zloishavrin',
    language_code: 'ru'
  },
  chat: {
    id: 764548588,
    first_name: 'shavrin',
    username: 'zloishavrin',
    type: 'private'
  },
  date: 1686339341,
  audio: {
    duration: 1,
    file_name: 'audio.mp3',
    mime_type: 'audio/mpeg',
    file_id: 'CQACAgIAAxkBAAICjWSDfw0AAZdXcrZjG-2n840P-NqNIQACOzEAAi3wIUh2fGtPn59fBi8E',
    file_unique_id: 'AgADOzEAAi3wIUg',
    file_size: 19776
  }
}

Хочу обратить внимание на то, что если пользователь скинет несколько видео, фото или аудио одним сообщением, то они не вернутся Вам одним сообщением с массивом audio или video-объектов, просто сработают несколько слушателей и эти файлы будут считаться отдельными сообщениями и обрабатываться будут также отдельно.

С голосовыми ситуация аналогичная, ничего интересного:

else if(msg.text == '⭐️ Голосовое сообщение') {

    await bot.sendVoice(msg.chat.id, './audio.mp3', {

        caption: '<b>⭐️ Голосовое сообщение</b>',
        parse_mode: 'HTML'

    });

}

И также обрабатываем голосовые сообщения:

bot.on('voice', async voice => {

    try {

        await bot.sendAudio(voice.chat.id, voice.voice.file_id, {

            caption: `Вес файла: ${voice.voice.file_size} байт\nДлительность аудио: ${voice.voice.duration} секунд`

        })

    }
    catch(error) {

        console.log(error);

    }

})
Бот отвечает на голосовые сообщения
Бот отвечает на голосовые сообщения

Скидываем, запрашиваем и обрабатываем контакт

Дальше разберемся с тем, как скинуть пользователю контакт. Для этого обработаем кнопку "⭐️ Контакт", используя метод sendContact, в который передадим строку с номером телефона и именем контакта:

else if(msg.text == '⭐️ Контакт') {

    //Скидываем контакт
    await bot.sendContact(msg.chat.id, process.env.CONTACT, `Контакт`, {

        reply_to_message_id: msg.message_id

    });

}

Обратите внимание, что в телеграме стоит проверка на формат номера, и если мы укажем не номер, то телеграм вернёт нам ошибку.

Также я добавил параметр reply_to_message_id - в этот параметр мы можем передать message_id сообщения, на которое мы хотим ответить сообщением, в которое передаем message_id.

Бот скидывает контакт
Бот скидывает контакт

Теперь давайте немного изменим нашу кнопку. И вместо того, чтобы просто скидывать контакт, мы сначала будем его запрашивать, а затем обрабатывать. Для этого изменим обработку команды "/menu", в которой мы присылаем пользователю нашу клавиатуру:

else if(msg.text == '/menu') {

    await bot.sendMessage(msg.chat.id, `Меню бота`, {

        reply_markup: {

            keyboard: [

                ['⭐️ Картинка', '⭐️ Видео'],
                ['⭐️ Аудио', '⭐️ Голосовое сообщение'],
                [{text: '⭐️ Контакт', request_contact: true}, '⭐️ Геолокация'],
                ['❌ Закрыть меню']

            ],
            resize_keyboard: true

        }

    })

}

Как видите, теперь передаём вместо просто строки '⭐️ Контакт' объект с параметрами text и request_contact, тем самым запрашивая контакт пользователя при нажатии на кнопку:

Бот запрашивает контакт пользователя
Бот запрашивает контакт пользователя

Теперь обработаем контакт. Для этого добавляем слушатель с типом "contact". Этот слушатель вернёт нам следующий объект, если пользователь скинул контакт:

{
  message_id: ID_СООБЩЕНИЯ,
  from: {
    id: ID_ПОЛЬЗОВАТЕЛЯ,
    is_bot: false,
    first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
    username: НИК_ПОЛЬЗОВАТЕЛЯ,
    language_code: 'ru'
  },
  chat: {
    id: ID_ЧАТА,
    first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
    username: НИК_ПОЛЬЗОВАТЕЛЯ,
    type: 'private'
  },
  date: ДАТА,
  reply_to_message: {
    message_id: ID_СООБЩЕНИЯ_В_КОТОРОМ_СКИДЫВАЛИ_КНОПКУ,
    from: {
      id: ID_БОТА,
      is_bot: true,
      first_name: ИМЯ_БОТА,
      username: НИК_БОТА
    },
    chat: {
      id: ID_ЧАТА,
      first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
      username: НИК_ПОЛЬЗОВАТЕЛЯ,
      type: 'private'
    },
    date: ДАТА_СООБЩЕНИЯ_В_КОТОРОМ_СКИДЫВАЛИ_КНОПКУ,
    text: ТЕКСТ_СООБЩЕНИЯ_В_КОТОРОМ_СКИДЫВАЛИ_КНОПКУ
  },
  contact: {
    phone_number: НОМЕР_КОНТАКТА,
    first_name: ИМЯ_КОНТАКТА,
    user_id: ID_ПОЛЬЗОВАТЕЛЯ
  }
}

Давайте обработаем это следующим образом: когда боту приходит контакт, бот скидывает номер и имя контакта, отвечая на сообщение с контактом:

bot.on('contact', async contact => {

    try {

        await bot.sendMessage(contact.chat.id, `Номер контакта: ${contact.contact.phone_number}\nИмя контакта: ${contact.contact.first_name}`);

    }
    catch(error) {

        console.log(error);

    }

})

Выглядеть это будет так:

Бот обрабатывает сообщение с контактом
Бот обрабатывает сообщение с контактом

Скидываем, запрашиваем и обрабатываем геолокацию

Обработаем нажатие кнопки "⭐️ Геолокация" и будем скидывать пользователю геолокацию Красной Площади, указав широту и долготу нужной нам координаты:

else if(msg.text == '⭐️ Геолокация') {

    const latitudeOfRedSquare = 55.753700;
    const longitudeOfReadSquare = 37.621250;

    await bot.sendLocation(msg.chat.id, latitudeOfRedSquare, longitudeOfReadSquare, {

        reply_to_message_id: msg.message_id

    })

}
Бот скидывает геолокацию
Бот скидывает геолокацию

Теперь провернём тоже самое, что мы делали с контактами, только для геолокации - сделаем запрос геолокации по кнопке и будем возвращать в ответ на сообщение с геолокацией координаты геолокации (широту и долготу). Начнём с того, что изменим кнопку "⭐️ Геолокация":

else if(msg.text == '/menu') {

    await bot.sendMessage(msg.chat.id, `Меню бота`, {

        reply_markup: {

            keyboard: [

                ['⭐️ Картинка', '⭐️ Видео'],
                ['⭐️ Аудио', '⭐️ Голосовое сообщение'],
                [{text: '⭐️ Контакт', request_contact: true}, {text: '⭐️ Геолокация', request_location: true}],
                ['❌ Закрыть меню']

            ],
            resize_keyboard: true

        }

    })

}
Бот запрашивает геолокацию
Бот запрашивает геолокацию

Далее обработаем сообщение пользователя с геолокацией. Сообщение с геолокацией возвращает следующий объект:

{
  message_id: ID_СООБЩЕНИЯ,
  from: {
    id: ID_ПОЛЬЗОВАТЕЛЯ,
    is_bot: false,
    first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
    username: НИК_ПОЛЬЗОВАТЕЛЯ,
    language_code: 'ru'
  },
  chat: {
    id: ID_ЧАТА,
    first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
    username: НИК_ПОЛЬЗОВАТЕЛЯ,
    type: 'private'
  },
  date: ДАТА,
  reply_to_message: {
    message_id: ID_СООБЩЕНИЯ_В_КОТОРОМ_СКИДЫВАЛИ_КНОПКУ,
    from: {
      id: ID_БОТА,
      is_bot: true,
      first_name: ИМЯ_БОТА,
      username: НИК_БОТА
    },
    chat: {
      id: ID_ЧАТА,
      first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
      username: НИК_ПОЛЬЗОВАТЕЛЯ,
      type: 'private'
    },
    date: ДАТА_СООБЩЕНИЯ_В_КОТОРОМ_СКИДЫВАЛИ_КНОПКУ,
    text: ТЕКСТ_СООБЩЕНИЯ_В_КОТОРОМ_СКИДЫВАЛИ_КНОПКУ
  },
  location: { latitude: ШИРОТА_ГЕОЛОКАЦИИ, longitude: ДОЛГОТА_ГЕОЛОКАЦИИ }
}

Сделаем следующий слушатель:

bot.on('location', async location => {

    try {

        await bot.sendMessage(location.chat.id, `Широта: ${location.location.latitude}\nДолгота: ${location.location.longitude}`);

    }
    catch(error) {

        console.log(error);

    }

})
Бот отвечает на сообщение с геолокацией
Бот отвечает на сообщение с геолокацией

Делаем ещё одно меню

Выше я уже говорил, что меню можно создать нескольких видов. Выше мы уже создали меню-клавиатуру и меню команд. Теперь создадим меню, как я писал выше, привязанное к сообщению. Далее будем называть это инлайн-клавиатура. Для этого создадим новую команду и обработаем её:

else if(msg.text == '/second_menu') {

    await bot.sendMessage(msg.chat.id, `Второе меню`, {

        reply_markup: {

            inline_keyboard: [

                [{text: 'Стикер', callback_data: 'sticker'}, {text: 'Круглое Видео', callback_data: 'circleVideo'}],
                [{text: 'Купить Файл', callback_data: 'buyFile'}],
                [{text: 'Проверить Подписку', callback_data: 'checkSubs'}],
                [{text: 'Закрыть Меню', callback_data: 'closeMenu'}]

            ]

        }

    })

}

Как видим мы также, как с обычной клавиатурой, создаём массив массивов в объекте reply_markup. Каждый массив в inline_keyboard - это новая строка в меню. В отличие от обычной клавиатуры, мы передаём в массив не строки, а объекты с текстом и callback_data. Обрабатывать мы будем нажатия кнопок не обычным текстовым сообщением, а уже коллбеками. Вот так будет выглядеть созданная нами инлайн-клавиатура:

Инлайн-клавиатура
Инлайн-клавиатура

Теперь давайте попробуем обработать кнопку "Закрыть Меню". Для этого будем использовать слушатель с типом "callback_query":

bot.on('callback_query', async ctx => {

    try {

        console.log(ctx);

    }
    catch(error) {

        console.log(error);

    }

})

Коллбеки возвращают нам с серверов телеграм следующий объект:

{
  id: ID_КОЛЛБЕКА,
  from: {
    id: ID_ПОЛЬЗОВАТЕЛЯ,
    is_bot: false,
    first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
    username: НИК_ПОЛЬЗОВАТЕЛЯ,
    language_code: 'ru'
  },
  message: {
    message_id: ID_СООБЩЕНИЯ,
    from: {
      id: ID_БОТА,
      is_bot: true,
      first_name: ИМЯ_БОТА,
      username: НИК_БОТА
    },
    chat: {
      id: ID_ЧАТА,
      first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
      username: НИК_ПОЛЬЗОВАТЕЛЯ,
      type: 'private'
    },
    date: ДАТА,
    text: ТЕКСТ_СООБЩЕНИЯ,
    reply_markup: { inline_keyboard: [Array] }
  },
  chat_instance: ЗАВИСИМЫЙ_ЧАТ,
  data: КОЛЛБЕК_ДАТА
}

Как видим, в объекте возвращается информация о сообщении, к которому привязана кнопка, на которую нажали, информация о пользователе, который нажал кнопку, и самое важное - callback_data, которую мы указывали в кнопке. С помощью вот этого параметра callback_data будем обрабатывать нажатие на кнопку:

bot.on('callback_query', async ctx => {

    try {

        switch(ctx.data) {

            case "closeMenu":

                await bot.deleteMessage(ctx.message.chat.id, ctx.message.message_id);
                break;

        }

    }
    catch(error) {

        console.log(error);

    }

})

В данном случае, мы удаляем сообщение с нашей инлайн-клавиатурой по нажатию кнопки "Закрыть Меню". Дальше в этом же слушателе будем обрабатывать нажатия других кнопок по параметру callback_data.

Теперь давайте немного модифицируем всё это дело. И наше меню будем скидывать не просто сообщением по команде, а в ответ на сообщение-команду:

else if(msg.text == '/second_menu') {

    await bot.sendMessage(msg.chat.id, `Второе меню`, {

        reply_markup: {

            inline_keyboard: [

                [{text: 'Стикер', callback_data: 'sticker'}, {text: 'Круглое Видео', callback_data: 'circleVideo'}],
                [{text: 'Купить Файл', callback_data: 'buyFile'}],
                [{text: 'Проверить Подписку', callback_data: 'checkSubs'}],
                [{text: 'Закрыть Меню', callback_data: 'closeMenu'}]

            ]

        },
        reply_to_message_id: msg.message_id

    })

}

Теперь мы можем сделать следующее: в обработчике закрытия меню удалять не только сообщение с самим меню, а ещё и удалять сообщение-команду, по которому было вызвано меню:

case "closeMenu":

  await bot.deleteMessage(ctx.message.chat.id, ctx.message.message_id);
  await bot.deleteMessage(ctx.message.reply_to_message.chat.id, ctx.message.reply_to_message.message_id);
  break;

Мы можем такое сделать, потому что тогда наш коллбек будет возвращать следующий объект:

{
  id: ID_КОЛЛБЕКА,
  from: {
    id: ID_ПОЛЬЗОВАТЕЛЯ,
    is_bot: false,
    first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
    username: НИК_ПОЛЬЗОВАТЕЛЯ,
    language_code: 'ru'
  },
  message: {
    message_id: ID_СООБЩЕНИЯ,
    from: {
      id: ID_БОТА,
      is_bot: true,
      first_name: ИМЯ_БОТА,
      username: НИК_БОТА
    },
    chat: {
      id: ID_ЧАТА,
      first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
      username: НИК_ПОЛЬЗОВАТЕЛЯ,
      type: 'private'
    },
    date: ДАТА,
    reply_to_message: {
      message_id: ID_СООБЩЕНИЯ_2,
      from: [Object],
      chat: [Object],
      date: ДАТА,
      text: КОМАНДА_ВЫЗОВА_МЕНЮ,
      entities: [Array]
    },
    text: ТЕКСТ_СООБЩЕНИЯ,
    reply_markup: { inline_keyboard: [Array] }
  },
  chat_instance: ЗАВИСИМЫЙ_ЧАТ,
  data: КОЛЛБЕК_ДАТА
}

Скидываем и обрабатываем стикеры

Далее обработаем нажатие кнопки "Стикер" на нашей инлайн-клавиатуре. Для этого будем использовать метод sendSticker:

case "sticker":

  await bot.sendSticker(ctx.message.chat.id, `./image.jpg`);
  break;

Хочу обратить ваше внимание на то, что мы можем скинуть любое изображение стикером, аналогично тому, как мы это делали с методом sendPhoto.

Теперь обработаем сообщение пользователя со стикером с помощью слушателя с типом "sticker", который будет возвращать нам следующий объект:

{
  message_id: ID_СООБЩЕНИЯ,
  from: {
    id: ID_ПОЛЬЗОВАТЕЛЯ,
    is_bot: false,
    first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
    username: НИК_ПОЛЬЗОВАТЕЛЯ,
    language_code: 'ru'
  },
  chat: {
    id: ID_ЧАТА,
    first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
    username: НИК_ПОЛЬЗОВАТЕЛЯ,
    type: 'private'
  },
  date: ДАТА,
  sticker: {
    width: ШИРИНА_СТИКЕРА,
    height: ДЛИНА_СТИКЕРА,
    emoji: '????', //ЭМОДЗИ К КОТОРОМУ ПРИВЯЗАН СТИКЕР
    set_name: ИМЯ_СТИКЕРА,
    is_animated: false, //БУЛЕВА ПЕРЕМЕННАЯ, КОТОРАЯ ОТОБРАЖАЕТ АНИМИРОВАН СТИКЕР ИЛИ НЕТ
    is_video: false, //БУЛЕВА ПЕРЕМЕННАЯ, КОТОРАЯ ОТОБРАЖАЕТ СТИКЕР ВИДЕОФОРМАТА ИЛИ НЕТ
    type: 'regular',
    thumbnail: {
      file_id: ID_МИНИАТЮРЫ,
      file_unique_id: УНИКАЛЬНЫЙ_ID_МИНИАТЮРЫ,
      file_size: РАЗМЕР_МИНИАТЮРЫ,
      width: ШИРИНА_МИНИАТЮРЫ,
      height: ДЛИНА_МИНИАТЮРЫ
    },
    thumb: {
      file_id: ID_МИНИАТЮРЫ,
      file_unique_id: УНИКАЛЬНЫЙ_ID_МИНИАТЮРЫ,
      file_size: РАЗМЕР_МИНИАТЮРЫ,
      width: ШИРИНА_МИНИАТЮРЫ,
      height: ДЛИНА_МИНИАТЮРЫ
    },
    file_id: ID_ФАЙЛА_СТИКЕРА,
    file_unique_id: УНИКАЛЬНЫЙ_ID_ФАЙЛА_СТИКЕРА,
    file_size: РАЗМЕР_ФАЙЛА_СТИКЕРА
  }
}

Давайте теперь сделаем следующее: когда пользователь скидывает стикер, будем проверять, какого вида стикер используя информацию из объекта (булевы переменные) и будем скидывать стикер пользователю картинкой, видео или анимацией:

bot.on('sticker', async sticker => {

    try {

        const stickerPath = await bot.downloadFile(sticker.sticker.file_id, './image');

        if(sticker.sticker.is_video) {

            await bot.sendVideo(sticker.chat.id, stickerPath);

        }
        else if(sticker.sticker.is_animated) {

            await bot.sendAnimation(sticker.chat.id, stickerPath);

        }
        else {

            await bot.sendPhoto(sticker.chat.id, stickerPath);

        }

        fs.unlink(stickerPath, error => {

            if(error) {

                console.log(error);

            }

        })

    }
    catch(error) {

        console.log(error);

    }

})
Бот отвечает на сообщение со стикером
Бот отвечает на сообщение со стикером

Обратите внимание, что я сделал некрасиво: в любом случае, будь стикер анимацей, видео или картинкой скачиваю файл в папку с названием "image" - так лучше, конечно, не делать. Также я не отправляю файлы по file_id, а скачиваю и затем их отправляю, потому что если попробовать скинуть стикер-файл по file_id используя методы sendPhoto или sendVideo, телеграм вернёт ошибку о том, что нельзя скинуть файл типа "стикер", как "фото" или "видео", однако, метод sendAnimation позволяет отправлять стикеры анимацией по их file_id, тогда это будет выглядеть следующим образом:

else if(sticker.sticker.is_animated) {

    await bot.sendAnimation(sticker.chat.id, sticker.sticker.file_id);

}

Круглое видео

Обработаем кнопку "Круглое Видео", по нажатию на которую будем скидывать пользователю видео круглого формата. Сделать это можно с помощью метода sendVideoNote:

case "circleVideo":

      await bot.sendVideoNote(ctx.message.chat.id, './video.mp4');
      break;

В общем-то с круглыми видео ничего интересного нету - всё аналогично обычному видео. Кстати, в объект опций мы можем передать параметр protect_content со значением true и пользователь не сможет пересылать сообщение:

case "circleVideo":

  await bot.sendVideoNote(ctx.message.chat.id, './video.mp4', {

      protect_content: true

  });
  break;

Проверка подписки на канал

Далее разберемся с тем, как сделать, чтобы бот проверял подписку на канал. Для этого необходимо поставить бота админом канала, на который он будет проверять подписку и использовать метод getChatMember:

case "checkSubs":

  const subscribe = await bot.getChatMember(process.env.ID_CHAT, ctx.from.id);
  console.log(subscribe);
  break;

В этот метод передаём ID нашего канала или чата - получить этот ID можно, например, скинув ссылку на канал или чат в специального бота, который возвращает ID, он будет в формате "-100XXXXXXXXXX", и также передаём в этот метод ID пользователя, подписку которого будем проверять. Данный метод вернёт объект такого вида:

{
  user: {
    id: ID_ПОЛЬЗОВАТЕЛЯ,
    is_bot: false,
    first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
    username: НИК_ПОЛЬЗОВАТЕЛЯ,
    language_code: 'ru'
  },
  status: СТАТУС_ПОЛЬЗОВАТЕЛЯ,
  is_anonymous: false
}

Будем обращаться к статусу пользователя. Статусы бывают следующие:

  • left - пользователь не подписан

  • kicked - пользователь заблокирован

  • member - пользователь подписан

  • administrator - пользователь является администратором

  • creator - пользователь является создателем

Обработаем статус пользователя и в зависимости от него будем выводить пользователю сообщение:

case "checkSubs":

  const subscribe = await bot.getChatMember(process.env.ID_CHAT, ctx.from.id);
  
  if(subscribe.status == 'left' || subscribe.status == 'kicked') {

      await bot.sendMessage(ctx.message.chat.id, `<b>Вы не являетесь подписчиком!</b>`, {

          parse_mode: 'HTML'

      })

  }
  else {

      await bot.sendMessage(ctx.message.chat.id, '<b>Вы являетесь подписчиком!</b>', {

          parse_mode: 'HTML'

      })

  }

  break;

Подключаем оплату

Для того, чтобы подключить оплату к своему бота для начала стоит определиться с платежной системой через которую будут совершаться платежи. У телеграма есть хороший выбор стандартных платежный систем, однако, никто не запрещает Вам подключить стороннюю платежную систему, но сейчас разберемся именно со стандартными телеграмовскими платежными системами. Для их подключения необходимо перейти в BotFather, выбрать своего бота, перейти в раздел Payments, а дальше в выбранной Вами платежной системе получить provider token - ключ, по которому мы будем подключать оплату.

Давайте отправим счёт на оплату пользователю по нажатию кнопки "Купить Файл". Для этого будем использовать метод sendInvoice:

case "buyFile":

  await bot.sendInvoice(ctx.message.chat.id, 
                      'Купить Файл', 
                      'Покупка файла', 
                      'file', 
                      process.env.PROVIDER_TOKEN, 
                      'RUB', 
                      [{
                          
                          label: 'Файл',
                          amount: 20000
                      
                      }]);

  break;

В этот метод мы передаём название платежа, описание платежа, payload - это информация, которая передаётся в платеж, по ней мы будем отслеживать нужный платеж, а у пользователя она нигде не отобразится, провайдер-токен, валюта (в разных платежках поддерживаются разные валюты) и также массив объектов с товарами, которые будет оплачивать пользователь. Обратите внимание, что валютой я указал рубли (код валюты в ISO 4217), а цену на товар указал в копейках. Также можно в метод передать другие параметры, можно добавить миниатюру платежа, включить необходимость ввода некоторых данных для пользователя, а также данные для фискализации платежа.

Далее по кнопке "Купить Файл" пользователю отправится следующее сообщение:

Бот отправляет счёт на оплату
Бот отправляет счёт на оплату

Далее пользователь нажмёт кнопку оплаты, введёт свои данные, и когда подтвердит введённые данные, бот должен отправить окончательное подтверждение перед оформлением заказа. Сделать это можно при помощи обработки такого слушателя:

bot.on('pre_checkout_query', async ctx => {

    try {

        await bot.answerPreCheckoutQuery(ctx.id, true);

    }
    catch(error) {

        console.log(error);

    }

})

Когда мы окончательно подтвердили оформление заказа, пользователь может совершить платеж и мы должны его как-то обработать. Для этого будем использовать слушатель с типом "successful_payment", который возвращает объект следующего вида:

{
  message_id: ID_СООБЩЕНИЯ,
  from: {
    id: ID_ПОЛЬЗОВАТЕЛЯ,
    is_bot: false,
    first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
    username: НИК_ПОЛЬЗОВАТЕЛЯ,
    language_code: 'ru'
  },
  chat: {
    id: ID_ЧАТА,
    first_name: ИМЯ_ПОЛЬЗОВАТЕЛЯ,
    username: НИК_ПОЛЬЗОВАТЕЛЯ,
    type: 'private'
  },
  date: ДАТА,
  successful_payment: {
    currency: КОД_ВАЛЮТЫ,
    total_amount: СУММА_ПЛАТЕЖА,
    invoice_payload: PAYLOAD,
    telegram_payment_charge_id: ID_ПЛАТЕЖА_ТЕЛЕГРАМ,
    provider_payment_charge_id: ID_ПЛАТЕЖА_ПЛАТЕЖНАЯ_СИСТЕМА
  }
}

Исходя из этой информации мы можем обработать совершённый платеж, например:

bot.on('successful_payment', async ctx => {

    try {

        await bot.sendDocument(ctx.chat.id, `./${ctx.successful_payment.invoice_payload}.txt`, {

            caption: `Спасибо за оплату ${ctx.successful_payment.invoice_payload}!`

        })

    }
    catch(error) {

        console.log(error);

    }

})

Используем метод sendDocument для того, чтобы скинуть пользователю файл, который мы находим благодаря информации из payload, и в подписи к нему выводим информацию из payload, которую мы изначально передали в методе sendInvoice. Пользователь увидит следующее:

Бот отвечает на успешный платеж
Бот отвечает на успешный платеж

Заключение

В этом материале постарался максимально просто и с примерами рассказать об основных возможностях телеграм-ботов и как ими управлять с помощью NodeJS. Тут в принципе есть ещё о чем рассказать: веб-хуки для телеграм ботов, управление чатами, каналами, игры и веб-приложения в телеграм-ботах. Если кому-то будет интересна эта тема, то продолжу об этом писать.

Также выложил код всего, что было написано в этой статье на GitHub с комментариями, посмотреть и скачать можно здесь.

Комментарии (3)


  1. delphinpro
    10.06.2023 17:41
    +6

    Вы просто переписали на русском часть документации. Запилите лучше отдельный ресурс с переводом документации. Полезнее будет.


  1. justalike
    10.06.2023 17:41

    Жаль что вы не описали корявость самой библиотеки)
    А callback_query listener-ы при кажущейся удобности работы доставляют множество мигреней если сразу их адекватно не структурировать.
    Как человек который знаком с этой библиотекой не по-наслышке рекомендую посмотреть альтернативы или обратиться к протоколу MTProto, который позволит сделать юзерботов.


  1. alexchekmenev
    10.06.2023 17:41

    Используемая в статье либа уже давным давно устарела. Лучше взять тот же Telegraf или новый grammY. Код в вашей статье конечно отдельное искусство :D