Делаем телеграм бота на JS

Создано специально для телеграм канала https://t.me/debug_u

Вступление

О чем будет это статья?

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

Пожалуйста, не воспринимайте меня как гуру JavaScript или библиотеки, которую мы будем использовать в процессе разработки. Я лишь хочу поделиться теми знаниями, которые сумел приобрести сам, и рассказать про подводные камни, с которыми столкнулся.

Те, кто знаком с NodeJS, можете пропустить или очень бегло просмотреть раздел "подготовки", где объясняется как создать приложение на ноде.

Также обращаю внимание, что я сижу на MacOS и использую команды для нее. Если у вас другая ОС либо же терминальные команды отличаются, попробуйте погуглить!

Больше информации вы можете узнать на канале Debug_Yourself.

Связаться с автором можно https://t.me/arutemu_su

Подготовка

Создаем проект на NodeJS

Как и было упомянуто выше, в качестве языка программирования мы выберем JS. Стоит ли знать его для того чтобы понять эту статью? Наверное да. Если вы не знаете JS хотя бы на уровне синтаксиса, многое будет вам непонятно, хотя я постараюсь осветить каждую строку кода по возможности. В любом случае никто вам не запрещает потратить пару часов/дней на то, чтобы познакомиться с JS и потом вернуться к этой статье.

Итак. Чтобы мы могли исполнять JS код на нашей машине, нам потребуется NodeJS. Многие читатели наверняка слышали/знают о том, что это такое. Если коротко: NodeJS (нода) – это платформа, которая позволяет исполнять JS код на компьютере. Скачайте ее в зависимости от вашей ОС. Если после установки ввести команду в терминале

node -v

вы должны получить версию по типу v14.2.0.

Теперь мы можем непосредственно создать проект на ноде. Для этого создайте папку на рабочем столе (или где вам удобнее) с названием проекта. Я назвал папку debug_u_bot.

Также нам потребуется редактор для написания кода. Какой выбирать – дело каждого. Я пользуюсь Visual Studio Code. Довольно хороший инструмент, можете попробовать.

Открываем редактор, а в нем и нашу созданную папку. Разумеется, сейчас там пусто.

Вместе с нодой идет такой инструмент как npm. Сам по себе npm – это менеджер пакетов, с помощью которого вы можете устанавливать в свой проект различные сторонние зависимости. Для начала давайте при помощи него инициализируем проект. Откройте терминал в вашем редакторе (в VSC он находится в верхнем меню), либо же можете открыть терминал отдельно (только вам потребуется перейти непосредственно в вашу папку). Введите там команду

npm init --yes

Этой командой вы инициализируете ваш проект, и в папке появится файл package.json. Часть команды --yes – это параметр, который автоматически пропускает вопросы по типу версии проекта, автора и т.п. Если запустить без него, то сможете ввести исходные данные, но в нашем случае в этом нет необходимости.

Итак. Если посмотреть на файл package.json, мы увидим следующее:

package.json
{
  "name": "debug_u_bot",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

Сам по себе этот файл определяет настройки проекта, скрипты и зависимости, которые этот проект использует. Позже мы вернемся к нему.

Создаем сервер на NodeJS

Теперь давайте создадим файл app.js в корне нашего каталога. Здесь мы будем писать наш будущий код. Однако прежде чем приступить к этому, нам необходимо установить несколько зависимостей. В терминале пишем команду

npm i express

Этой командой мы говорим npm, чтобы он установил пакет express. В данном случае i – сокращение от install. Вы также можете написать npm install express и получить аналогичный результат.

Что же такое express? Это минималистичный фреймворк для написаний NodeJS приложений. Он нам потребуется для быстрого создания сервера. Кому интересно, можете почитать про него тут.

Итак. Открываем наконец-таки файл app.js и пишем следующее

app.js
const express = require('express')

const app = express()
const PORT = 3000

app.get('/', (req, res) => {
    res.send('Hello debug_Yourself')
})

app.listen(PORT, () => console.log(`My server is running on port ${PORT}`))

Коротко пробежимся что к чему: на первой строке мы добавляем модуль 'express' и кладем его в переменную express На 3 строке мы инициализируем наше приложение и сохраняем в переменной app. На 4 строке создаем переменную с портом, на котором будет заводиться наш сервер. На 6 строке создается обработчик для корневого запроса '/', но об этом чуть ниже. На 10 строке мы запускаем наш HTTP сервер на заданном ранее порту PORT.

Введите в терминале команду node app или node app.jsи, если все сделано правильно, у вас запуститься локальный сервер. Также в терминале вы увидите следующее

Если в браузере перейти по адресу http://lcalhost:3000/ , то вы увидите

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

В файле app.js попробуйте заменить текст, который выводится при обработке get запроса

app.js
const express = require('express')

const app = express()
const PORT = 3000

// ИЗМЕНИТЕ ТЕКСТ В res.send()
app.get('/', (req, res) => {
    res.send('Hello. I changed this text')
})

app.listen(PORT, () => console.log(`My server is running on port ${PORT}`))

Теперь по идее если опять зайти на http://localhost:3000/, то текст должен поменяться, но этого не происходит. Причина кроется в том, что для того, чтобы наши изменения заимели силу, необходимо вручную перезапустить сервер. Сначала остановите его (ctrl+c), а потом снова запустите командой node app. Теперь изменения вступили в силу, однако каждый раз вручную перезагружать сервер утомляет.

Чтобы сервер сам автоматически перезагружался после каждого нашего изменения, давайте установим еще один пакет через терминал npm i nodemon -g. Это установит данный пакет глобально. ВАЖНО! У меня на MacOS для глобальной установки необходимо ставить через sudo, то есть sudo npm i nodemon -g.

Теперь вы можете запустить проект в терминале командой nodemon app. Вновь измените текст на что-нибудь и сохраните файле app.js. Сервер рестартанет сам, и вы увидите изменения.

Последнее, что хотелось бы сделать, это внести пару изменений. Для импорта и экспорта файлов я предпочел бы использовать более современный синтаксис import/from. Давайте изменим 1 строку файла app.js следующим образом:

app.js
import express from 'express'

const app = express()
const PORT = 3000

app.get('/', (req, res) => {
    res.send('Hello. I changed this text again')
})

app.listen(PORT, () => console.log(`My server is running on port ${PORT}`))

Чтобы код работал, нам также необходимо кое-что добавить в файле package.json

package.json
{
  "name": "debug_u_bot",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.17.1",
    "nodemon": "^2.0.4"
  },
  "type": "module"
}

а именно надо добавить еще одно поле type со значением module. Можете в конец через запятую вставить "type": "module" или просто скопировать мой код. Также давайте пропишем скрипт для более лаконичного запуска нашего приложения. На 6 строке "scripts" удалить test и замените его на "start": "nodemon app.js". Этим мы по сути команду nodemon app.js просто пакуем в команду start. Вот как должен выглядеть файл package.json теперь

package.json
{
  "name": "debug_u_bot",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "nodemon app.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.17.1",
    "nodemon": "^2.0.4"
  },
  "type": "module"
}

Теперь чтобы запустить проект, введите в терминал команду npm run start.

Создаем телеграм бота

Что за бота мы будем писать?

В качестве примера мы напишем бота, в котором можно хранить задачи. Получиться своего рода taskManagerBot с минимальным функционалом. При это в процессе его создания мы сможем рассмотреть много базовых тем и приемов.

Просим благословения у BotFather.

Всех ботов в тг надо создавать через отца всея ботов - @BotFather. Командой /newbotприступаем к созданию, введя имя (я ввел debug_u_lesson). После надо ввести username вашего бота. Разумеется, он должен быть уникальным для телеграма и заканчиваться на bot. Я ввел debug_u_lesson_bot, вы придумайте что-то свое.

Введя все этого, мы получаем наш заветный token, ради которого все и затевалось. Желательно его никому не показывать, ведь иначе кто-то другой сможет управлять вашим ботом. Хотя вы всегда можете поменять токен в BotFather. Там же вы можете немного кастомизировать бота, если напишите /mybots. Там можно изменять имя/описание/пикчу или подключать платежи для сбера/яндекса. Попробуйте изменить описание на что-то подобное "Приветствую. Я учусь создавать ботов вместе с @debug_u".

Начинаем кодить

Несмотря на то, что проект предвещает быть простым и незамысловатым, предлагаю не мешать все в один файл app.js. Давайте такие вещи как порт сервера и токен телеграма мы будем хранить в отдельном файле. Сразу скажу, что в идеале их надо хранить в переменных окружения, но в нашем случае не хочу пока этим грузить. Если хотите почитать про то, что это значит, то вот.

В общем, создаем файл config.js в корне нашего проекта и пишем туда следующее:

config.js
export const PORT = 3000
export const TOKEN = 'токен вашего бота'

Здесь мы просто объявляем две константы и командой export позволяем использовать их в других файлах/модулях.

Наш файл app.js немного видоизменится:

app.js
import express from 'express'
import { PORT, TOKEN } from './config.js'

const app = express()

app.listen(PORT, () => console.log(`My server is running on port ${PORT}`))

Попробуйте еще раз запустить сервер командой npm run start. Если ошибок нет, значит все хорошо. Теперь нам надо установить саму библиотеку Telegraf, которая упростит разработку бота. В терминале введите команду ниже и дождитесь установки.

npm install telegraf --save

Давайте научим бота приветствовать нас и что-то отвечать. Напишите следующее:

app.js
import express from 'express'
import { PORT, TOKEN } from './config.js'
import Telegraf from 'telegraf'

const app = express()
const bot = new Telegraf(TOKEN)

bot.start(ctx => {
    ctx.reply('Welcome, bro')
})

bot.on('text', ctx => {
    ctx.reply('just text')
})

bot.launch()
app.listen(PORT, () => console.log(`My server is running on port ${PORT}`))

Давайте по порядку. На 6 строке мы создаем экземпляр класса Telegraf (в качестве параметра передаем наш TOKEN) и кладем его в переменную bot. По сути bot – это объект, содержащий разные обработчики (middlewares) для обработки запросов. Что значат эти обработчики?

Взгляните на 8 строку. Здесь мы пишем обработчик, который сработает, когда пользователь введет команду /start. То есть у объекта bot мы вызываем метод start, а в него передаем callback-функцию, которая принимает на вход один параметр - контекст (ctx), и на выходе отправляет сообщение 'Welcome, bro'.

Аналогично строка 12. Этот обработчик будет реагировать на любой текст, введенный пользователем. Почему именно текст? Потому что мы явно в качестве первого параметра метода on у объекта bot указываем 'text'. Вторым параметром в on мы передаем callback-функцию, которая также принимает ctx и отвечает строкой 'just text'.

Очень важно не забыть запустить бота на 16 строке, вызвав метод launch().

Прежде, чем я познакомлю вас еще с некоторыми способами обработки, хочу обратить внимание. Порядок написания обработчиков имеет значение! Что я имею ввиду? Сейчас, если вы зайдете в своего бота и введете команду /start, то будете получать сообщение 'Welcome, bro', а если будете вводить любой текст, то 'just text'. Теперь попробуйте поменять местами два этих обработчика и вы увидите, что введя команду /start, вы получите 'just text'. И если вы введете любой текст, то получите этот же результат. Все потому, что обработчики проверяются сверху вниз и выполнится тот, который подходит под условие первым. Команда /start– это по сути тот же текст, поэтому срабатывает bot.on('text'). Убедившись в этом, можете вернуть порядок обработчиков обратно.

Давайте рассмотрим на что еще может реагировать бот? На самом деле на очень много вещей. Все их я сейчас физически не рассмотрю, но покажу принцип.

app.js
import express from 'express'
import { PORT, TOKEN } from './config.js'
import Telegraf from 'telegraf'

const app = express()
const bot = new Telegraf(TOKEN)


bot.start(ctx => {
    ctx.reply('Welcome, bro')
})

bot.on('text', ctx => {
    ctx.reply('just text')
})

bot.on('voice', ctx => {
    ctx.reply('Какой чудный голос')
})

bot.on('sticker', ctx => {
    ctx.reply('Прикольный стикер')
})

bot.on('edited_message', ctx => {
    ctx.reply('Вы успешно изменили сообщение')
})

bot.launch()
app.listen(PORT, () => console.log(`My server is running on port ${PORT}`))

Мы можем передавать в метод on разные тригеры, на которые будет реагировать бот и делать что-то полезное. Пока же он нам просто отвечает сообщением, но позже мы это поправим. На строке 17, к примеру, обработчик реагирует на присланные нами войс-сообщения и говорит, что у нас чудный голос. На 21 строке аналогично, только реакция на стикер. На 25 строке немного интереснее: если мы изменим какое-либо свое сообщение, бот среагирует на это. И на самом деле таких тригеров много. Ваш редактор обязательно вам подскажет, что можно вставить.

"А бот может реагировать не просто на сообщение, а на конкретный текст?" – спросите вы. Да. Смотрите:

app.js
import express from 'express'
import { PORT, TOKEN } from './config.js'
import Telegraf from 'telegraf'

const app = express()
const bot = new Telegraf(TOKEN)


bot.start(ctx => {
    ctx.reply('Welcome, bro')
})

bot.hears('хочу есть', ctx => {
    ctx.reply('Так передохни и покушай')
})

bot.command('time', ctx => {
    ctx.reply(String(new Date()))
})

bot.launch()
app.listen(PORT, () => console.log(`My server is running on port ${PORT}`))

На 13 строке мы у объекта bot вызываем метод hears(), куда передаем конкретный текст, на который хотим, чтобы бот среагировал и обработал. Теперь, если вы напишите боту "хочу есть", получить вразумительный ответ.

Также обратите внимание на строку 17. Если мы хотим обработать команду по типу /start, то вызываем метод command. В данном случае, если отправить боту /time, то получите полную дату и время.

В принципе для начала этих обработчиков будет более, чем достаточно.

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

Давайте теперь рассмотрим клавиатуры, которые, кстати, хранить будем в файле keyboards.js (создайте его в корне вашего проекта). Клавиатуры могут быть как текстовые (при нажатии на кнопки вы отправляете боту текстовое сообщение) и инлайн (при нажатии вы отправляете callback_data). На примерах вам станет все яснее.

Перейдите в созданный файл keyboards.js и импортируйте класс Markup вначале import Markup from 'telegraf/markup'. Все клавиатуры будут обернуты в функции, которые мы будем экспортировать. Давайте для начала сделаем клавиатуру, которая будет иметь кнопки "Мои задачи" для просмотра текущих заданий, "Добавить задачу" для добавления новых задач и "Смотивируй меня" для мотивации. Вот что вам необходимо написать:

keyboards.js
import Markup from 'telegraf/markup.js'

export function getMainMenu() {
    return Markup.keyboard([
        ['Мои задачи', 'Добавить задачу'],
        ['Смотивируй меня']
    ]).resize().extra()
}

На 1 строке мы импортируем класс, предназначенный для клавиатур. На 3 строке создается функция getMainMenu(), которая возвращает клавиатуру. Важно, ее надо экспортировать командой export.

Так как нам нужно простое текстовое меню, мы на 4 строке вызываем у Markup метод keyboard(). В него надо передать один массив []. А в этот массив мы уже передаем массивы с нашими кнопками. Каждый такой массив соответствует ряду. В данном случае у нас клавиатура в два ряда, где в первом 2 кнопки, во втором 1.

На 7 строке мы вызываем метод resize(). Он отвечает за размерность кнопок. Если его убрать, то в моб. версии будет что-то подобное:

Также очень важно вызвать метод extra(), иначе клавиатура у вас просто не отобразится.

Итак, клавиатура готова, теперь можно ее использовать. В файле app.js нам следует импортировать (4 строка) нашу функцию getMainMenu(). По логике нам надо, чтобы пользователь видел клавиатуру сразу, как только зашел в бота. Так как первым делом пользователь всегда запускает команду /start, то давайте отправлять клавиатуру в этом обработчике.

app.js
import express from 'express'
import { PORT, TOKEN } from './config.js'
import Telegraf from 'telegraf'
import { getMainMenu } from './keyboards.js'

const app = express()
const bot = new Telegraf(TOKEN)

bot.start(ctx => {
    ctx.reply('Welcome, bro', getMainMenu())
})

bot.hears('хочу есть', ctx => {
    ctx.reply('Так передохни и покушай')
})

bot.command('time', ctx => {
    ctx.reply(String(new Date()))
})

bot.launch()
app.listen(PORT, () => console.log(`My server is running on port ${PORT}`))

Обработчик этой команды - это 9 строка. На 10 строке у объекта ctx мы вызываем метод reply(), который позволяет боту отвечать пользователю. И если первым параметром мы передаем туда текст ответа, то следующим мы можем отправить нашу клавиатуру. Наша функция как раз таки и возвращает клавиатуру, поэтому можем смело вызвать ее там.

Если вы все сделали верно, то при команде /start у вас появляется клавиатура, при нажатии на которую мы отправляем боту определенный текст.

На самом деле это и есть ее главная задумка. Мы хотим получать от пользователя конкретные сообщения, и чтобы он их не писал руками, мы даем возможность сделать это одним кликом. Более того, мы с вами уже умеем писать обработчика, который должен реагировать на определенный текст. Это метод hears().

К слову, метод hears() может принимать на вход не только строку, но и регулярное выражение и даже массив того и другого. Мы не будем сейчас усложнять процесс и для каждой кнопки клавиатуры напишем отдельные обработчики. Вот что у меня получилось:

app.js
import express from 'express'
import { PORT, TOKEN } from './config.js'
import Telegraf from 'telegraf'
import { getMainMenu } from './keyboards.js'

const app = express()
const bot = new Telegraf(TOKEN)

bot.start(ctx => {
    ctx.reply('Welcome, bro', getMainMenu())
})

bot.hears('Мои задачи', ctx => {
    ctx.reply('Тут будут ваши задачи')
})

bot.hears('Добавить задачу', ctx => {
    ctx.reply('Тут вы сможете добавить свои задачи')
})

bot.hears('Смотивируй меня', ctx => {
    ctx.replyWithPhoto(
        'https://img2.goodfon.ru/wallpaper/nbig/7/ec/justdoit-dzhastduit-motivaciya.jpg',
        {
            caption: 'Не вздумай сдаваться!'
        }
    )
})

bot.launch()
app.listen(PORT, () => console.log(`My server is running on port ${PORT}`))

На 13 и 17 строках пока что написаны обработчики, которые лишь отвечают текстом, но вскоре мы это поправим. Куда интереснее строка 21. Этот обработчик отвечает нам не текстом, а фотографией с подписью. Так вы можете заметить, что у объекта ctx достаточно много методов и способов ответа. Первым параметром в replyWithPhoto() мы передаем URL фотографии (я взял просто из инета). Вторым параметром мы передаем объект с прочими настройками. В данном случае поле caption – это описание фото (просто текст).

Давайте поработаем над логикой нашего бота. Задачи по-хорошему надо хранить в БД, но, опять же, не будем усложнять себе работу и сфокусируемся на боте. Пусть будет массив, имитирующий нашу базу данных, куда будет сохраняться каждая задача. Сама по себе задача будет JS строкой.

Давайте создадим файл db.js также в корне нашего проекта, и там создадим массив taskList. Разумеется, не забываем его экспортировать.

db.js
export let taskList = []

Тепер в файле app.js напишем обработчик, который будет реагировать на любой текст.

Взгляните на код, ниже я поясняю все, что добавил.

app.js
import express from 'express'
import { PORT, TOKEN } from './config.js'
import Telegraf from 'telegraf'
import { getMainMenu, yesNoKeyboard } from './keyboards.js'


const app = express()
const bot = new Telegraf(TOKEN)

bot.start(ctx => {
    ctx.replyWithHTML(
        'Приветсвую в <b>TaskManagerBot</b>\n\n'+
        'Чтобы быстро добавить задачу, просто напишите ее и отправьте боту',
        getMainMenu())
})

bot.hears('Мои задачи', ctx => {
    ctx.reply('Тут будут ваши задачи')
})

bot.hears('Добавить задачу', ctx => {
    ctx.reply('Тут вы сможете добавить свои задачи')
})

bot.hears('Смотивируй меня', ctx => {
    ctx.replyWithPhoto(
        'https://img2.goodfon.ru/wallpaper/nbig/7/ec/justdoit-dzhastduit-motivaciya.jpg',
        {
            caption: 'Не вздумай сдаваться!'
        }
    )
})

bot.on('text', ctx => {
    ctx.replyWithHTML(
        `Вы действительно хотите добавить задачу:\n\n`+
        `<i>${ctx.message.text}</i>`,
        yesNoKeyboard()
    )
})

bot.launch()
app.listen(PORT, () => console.log(`My server is running on port ${PORT}`))

Во-первых, на 10 строке я немного изменил обработку команды /start. Вместо простого метода reply(), я вызываю replyWithHTML(). Как несложно догадаться, он позволяет нам использовать некоторые теги html, чтобы преображать текст. В частности, тегами <b> можно сделать текст жирным. Еще обратите внимание на часть строки \n. Это перевод текста на новую строку. Соответственно, двумя такими \n я перевожу на две строки вниз. Плюс, чтобы код помещался на экран, я строку разбил на две и просто произвожу конкатенацию строк оператором +.

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

Аналогично вызывается replyWithHTML(), где происходит конкатенация строк. Но важно отметить, что кавычки здесь `` специальные. В JS в подобные строки мы можем вставлять наши переменные путем вызова ${сюда наш объект}, что и происходит на строке 37. Через объект ctx мы можем получить сообщение, которое пользователь отправил нам путем вызова ctx.message.text.

На 38 строке передается клавиатура. Разумеется, ее необходимо создать в файле keyboards.js

keyboards.js
import Markup from 'telegraf/markup.js'

export function getMainMenu() {
    return Markup.keyboard([
        ['Мои задачи', 'Добавить задачу'],
        ['Смотивируй меня']
    ]).resize().extra()
}

export function yesNoKeyboard() {
    return Markup.inlineKeyboard([
        Markup.callbackButton('Да', 'yes'),
        Markup.callbackButton('Нет', 'no')
    ], {columns: 2}).extra()
}

Инлайн клавы создаются несколько иначе. Во-первых, у Markup вызывается соответсвующий метод inlineKeyboard(). В него также передается массив, в который можно передавать как массив объектов кнопок, так и просто кнопки. В callbackButton() нужно передать текст кнопки и callback_data. Эти 'yes' и 'no' будут являться триггерами при нажатии на них. Также вторым параметром в inlineKeyboard() мы можем передать кол-во колонок, которое нам надо. И не забываем вызвать метод extra().

Сейчас, если боту что-то написать, как раз таки нам придет ответ типа

Как и отправляемый текст/войс/картинки/прочее, мы можем обрабатывать инлайн кнопки. Для этого у объекта bot необходимо вызывать метод action(). Взгляните на код:

app.js
import express from 'express'
import { PORT, TOKEN } from './config.js'
import Telegraf from 'telegraf'
import { getMainMenu, yesNoKeyboard } from './keyboards.js'
import { addTask } from './db.js'


const app = express()
const bot = new Telegraf(TOKEN)



bot.start(ctx => {
    ctx.replyWithHTML(
        'Приветсвую в <b>TaskManagerBot</b>\n\n'+
        'Чтобы быстро добавить задачу, просто напишите ее и отправьте боту',
        getMainMenu())
})

bot.hears('Мои задачи', ctx => {
    ctx.reply('Тут будут ваши задачи')
})

bot.hears('Добавить задачу', ctx => {
    ctx.reply('Тут вы сможете добавить свои задачи')
})

bot.hears('Смотивируй меня', ctx => {
    ctx.replyWithPhoto(
        'https://img2.goodfon.ru/wallpaper/nbig/7/ec/justdoit-dzhastduit-motivaciya.jpg',
        {
            caption: 'Не вздумай сдаваться!'
        }
    )
})

bot.on('text', ctx => {
    ctx.session.taskText = ctx.message.text

    ctx.replyWithHTML(
        `Вы действительно хотите добавить задачу:\n\n`+
        `<i>${ctx.message.text}</i>`,
        yesNoKeyboard()
    )
})

bot.action(['yes', 'no'], ctx => {
    if (ctx.callbackQuery.data === 'yes') {
        addTask('сюда будем передавать текст задачи')
        ctx.editMessageText('Ваша задача успешно добавлена')
    } else {
        ctx.deleteMessage()
    }
})

bot.launch()
app.listen(PORT, () => console.log(`My server is running on port ${PORT}`))

На 47 строке мы пишем обработчик наших callback_data. Так как кода на обработку будет мало, мы вместо того, чтобы писать два отдельных обработчика под 'yes' и 'no', создадим один, куда можно передать массив строк. Однако, в зависимости от того, какая кнопка была нажата, нам надо совершить разные деяния. Если это было "no", то мы будем удалять последнее сообщение, которое прислал бот. То есть это будет как раз сообщение с вопросом о добавлении задачи. Если это было "yes", то нам надо добавить пользовательское сообщение (его задачу) в массив taskList (который находится в файле db.js). После можно отредактировать последнее сообщение, присланное ботом, сказав пользователю, что его задача успешно добавлена.

Как нам получить параметры кнопок? Все просто. Если к объекту ctx обратиться так ctx.callbackQuery.data, то можно получить значение той кнопки, которая была нажата.

Вы спросите, что за функция addTask()на 49 строке? И это справедливый вопрос. Это будет своего рода имитация добавления данных в реальную базу данных. Разумеется, функцию надо написать в файле db.js и импортировать в app.js (строка 5).

let taskList = []

export function addTask(text) {
    taskList.push(text)
}

На 3 строке мы объявляем функцию и экспортируем ее. Функция принимает параметр text – текст сообщения, которое пользователь отправляет боту. На 4 строке мы добавляем text в массив методой push().

Теперь возникает небольшая головоломка. Когда пользователь отправляет боту сообщение, имеется один контекст ctx. Мы можем спокойно получить текст его сообщения командой ctx.message.text. Однако после этого пользователь жмет на инлайн кнопки "да/нет", и в таком случае контекст перезаписывается. То есть, если сейчас попробовать посмотреть текст сообщения (как я показал выше) в обработчике на 47 строке (файл app.js), то вы получите ошибку. Как быть?

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

app.js
import express from 'express'
import { PORT, TOKEN } from './config.js'
import Telegraf from 'telegraf'
import session from 'telegraf/session.js'
import { getMainMenu, yesNoKeyboard } from './keyboards.js'
import { addTask } from './db.js'

const app = express()
const bot = new Telegraf(TOKEN)

bot.use(session())

bot.start(ctx => {
    ctx.replyWithHTML(
        'Приветсвую в <b>TaskManagerBot</b>\n\n'+
        'Чтобы быстро добавить задачу, просто напишите ее и отправьте боту',
        getMainMenu())
})

bot.hears('Мои задачи', ctx => {
    ctx.reply('Тут будут ваши задачи')
})

bot.hears('Добавить задачу', ctx => {
    ctx.reply('Тут вы сможете добавить свои задачи')
})

bot.hears('Смотивируй меня', ctx => {
    ctx.replyWithPhoto(
        'https://img2.goodfon.ru/wallpaper/nbig/7/ec/justdoit-dzhastduit-motivaciya.jpg',
        {
            caption: 'Не вздумай сдаваться!'
        }
    )
})

bot.on('text', ctx => {
    ctx.session.taskText = ctx.message.text

    ctx.replyWithHTML(
        `Вы действительно хотите добавить задачу:\n\n`+
        `<i>${ctx.message.text}</i>`,
        yesNoKeyboard()
    )
})

bot.action(['yes', 'no'], ctx => {
    if (ctx.callbackQuery.data === 'yes') {
        addTask(ctx.session.taskText)
        ctx.editMessageText('Ваша задача успешно добавлена')
    } else {
        ctx.deleteMessage()
    }
})

bot.launch()
app.listen(PORT, () => console.log(`My server is running on port ${PORT}`))

На 4 строке мы импортируем session. Так как это middleware, то его необходимо подключить до всех обработчиков, что мы и делаем на 11 строке. Думаю, вы помните, что на пользовательский текст срабатывает обработчик на строке 37. Поэтому в нем, на 38 строке мы создаем переменную ctx.session.taskText и кладем в нее текст сообщения ctx.message.text. Теперь мы сможем вызывать ctx.session.taskText почти везде, где захотим. К слову, любую переменную сессии вы можете создать по форме ctx.session.<название переменной>.

Наконец, на 49 строке мы передаем эту переменную в качестве параметра функции addTask. Если вы все сделали правильно, то добавление должно работать, правда визуально мы не можем посмотреть задачи. Можете в функцию addTask в самом конце добавить console.log(taskList) и посмотреть в консоле.

Добавляем возможность посмотреть задачи

Как я и говорил, наш файл db.js служит некой имитацией работы с реальной базой. Однако в жизни запросы к БД – это асинхронные операции, которые могут занимать некоторое время. Это значит, к примеру, если мы пишем запрос типа getDataFromDB(), то он не выполнится именно в тот момент, когда будет вызван. Вдруг там запрос на получение миллиона записей, и он может занять пару секунд. Если весь оставшийся код будет ждать, пока выполнится такой запрос, будет не очень круто. И уже тем более, если будет ждать сам пользователь.

В общем давайте добавим функцию, которая позволит получить данные массива taskList.

db.js
let taskList = []

export function getMyTasks() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(taskList)
        }, 500)
    })
}

export function addTask(text) {
    taskList.push(text)
}

На 3 строке создаем и экспортируем функцию getMyTasks(). Эта функция будет возвращать так называемый промис, почитать о которых вы можете здесь. В промис передается функция обратного вызова с одним параметром resolve. Внутри промиса также вызывается функция setTimeout(). Она позволяет вызывать какую-либо функцию через определенный интервал. Пусть это будет 500 миллисекунд. А вызывать она будет resolve() с нашим массивом taskList. Здесь довольно много кода, но весь он направлен на то, чтобы мы смогли асинхронно получить массив с задачами через 500 миллисекунд.

Теперь надо нашу функцию импортировать в app.js и вызывать в соответствующем обработчике.

app.js
import express from 'express'
import { PORT, TOKEN } from './config.js'
import Telegraf from 'telegraf'
import session from 'telegraf/session.js'
import { getMainMenu, yesNoKeyboard } from './keyboards.js'
import { getMyTasks, addTask } from './db.js'

const app = express()
const bot = new Telegraf(TOKEN)

bot.use(session())

bot.start(ctx => {
    ctx.replyWithHTML(
        'Приветсвую в <b>TaskManagerBot</b>\n\n'+
        'Чтобы быстро добавить задачу, просто напишите ее и отправьте боту',
        getMainMenu())
})

bot.hears('Мои задачи', async ctx => {
    const tasks = await getMyTasks()
    let result = ''

    for (let i = 0; i < tasks.length; i++) {
        result = result + `[${i+1}] ${tasks[i]}\n`
    }

    ctx.replyWithHTML(
        '<b>Список ваших задач:</b>\n\n'+
        `${result}`
    )
})

bot.hears('Добавить задачу', ctx => {
    ctx.reply('Тут вы сможете добавить свои задачи')
})

bot.hears('Смотивируй меня', ctx => {
    ctx.replyWithPhoto(
        'https://img2.goodfon.ru/wallpaper/nbig/7/ec/justdoit-dzhastduit-motivaciya.jpg',
        {
            caption: 'Не вздумай сдаваться!'
        }
    )
})

bot.on('text', ctx => {
    ctx.session.taskText = ctx.message.text

    ctx.replyWithHTML(
        `Вы действительно хотите добавить задачу:\n\n`+
        `<i>${ctx.message.text}</i>`,
        yesNoKeyboard()
    )
})

bot.action(['yes', 'no'], ctx => {
    if (ctx.callbackQuery.data === 'yes') {
        addTask(ctx.session.taskText)
        ctx.editMessageText('Ваша задача успешно добавлена')
    } else {
        ctx.deleteMessage()
    }
})

bot.launch()
app.listen(PORT, () => console.log(`My server is running on port ${PORT}`))

Обработчик на 20 строке претерпел изменения! Во-первых, наша callback-функция с одним параметром ctx заимела оператор async и выглядит теперь async ctx => {...}. Подробнее про async/await вы сможете почитать здесь. Я лишь скажу, что эти операторы упрощают работу с асинхронным кодом. Так как функция для получения всех задач возвращает промис, то нам надо его обработать. Для обработки промисов есть несколько подходов, но в нашем примере будет использоваться оператор await. Однако чтобы использовать этот оператор внутри функций, сами функции должны быть явно объявлены с оператором async. По этой причине он и был добавлен.

На 21 строке мы вызываем функцию getMyTasks() с оператором await (конечно, не забываем импортировать ее на строке 6), что дает нам на выходе массив, который мы сохраняем в переменную tasks.

Так как показывать пользователю массив не лучший вариант, мы каждый его элемент будем в виде строки сохранять в одну общую строку result (создаем на 22 строке). Для того, чтобы пробежаться по массиву в JS, можно использовать простой цикл for. Чтобы каждая задача имела свой уникальный номер, мы просто будем выводить ее индекс из массива задач, увеличенный на единицу (увеличиваем т.к. индексация начинается с нуля, а не единицы).

После чего на 28 строке при помощи replyWithHTML() выводим весь список задач. Попробуйте запустить сервер, добавьте пару задач и нажмите на "Мои задачи". Если все работает, то поздравляю вас! Осталось только сделать удаление задач и на этом можно заканчивать.

Удаление задач

Кнопка "Добавить задачу" по факту оказалась бесполезной. Предлагаю заменить ее на "Удалить задачу". Для этого поменяйте текст в файле keyboards.js, а также текст обработчика на 34 строке в файле app.js. Чтобы изменения вступили в силу, необходимо в боте написать /start.

Пусть логика будет простая: удалить задачу можно будет только если отправить боту сообщение "удалить <порядковый номер задачи>". При нажатии на кнопку "Удалить задачу" мы будем сообщать пользователю об той инструкции. Выглядеть этот обработчик будет так:

app.js
// some code

bot.hears('Удалить задачу', ctx => {
    ctx.replyWithHTML(
        'Введите фразу <i>"удалить `порядковый номер задачи`"</i>, чтобы удалить сообщение,'+
        'например, <b>"удалить 3"</b>:'
    )
})

// some code

Для определения ключевой фразы удаления задач можно использовать регулярные выражения. Пусть после обработчика "Удалить задачу" будет новый обработчик, который будет реагировать на нужный нам текст.

app.js
// some code

bot.hears('Удалить задачу', ctx => {
    ctx.replyWithHTML(
        'Введите фразу <i>"удалить `порядковый номер задачи`"</i>, чтобы удалить сообщение,'+
        'например, <b>"удалить 3"</b>:'
    )
})

bot.hears(/^удалить\s(\d+)$/, ctx => {
    const id = Number(+/\d+/.exec(ctx.message.text)) - 1
    deleteTask(id)
    ctx.reply('Ваша задача успешно удалена')
})

// some code

На 10 строке у нас идет как раз таки регулярное выражение. На 11 строке мы берем ctx.message.text, где содержится текст, к примеру, "удалить 2". Чтобы из него вычленить цифру, а именно она нам и нужна, используется еще одно регулярное выражение +/\d+/.exec(). Но эта цифра имеет строковый тип, а нам нужен целочисленный, поэтому мы преобразовываем при помощи Number(). И теперь от числа можем смело отнимать единицу. Это нужно затем, что выше мы прибавляли единицу, чтобы нумерация задач для пользователя отображалась с 1. Индексы же массива начинаются с нуля, и чтобы правильно по ним удалять, нужно снова отнять единицу. Весь полученный результат сохраняется в переменную id.

На 12 строке вызывается метод deleteTask(), в который надо передать id. Разумеется, его надо написать. Для этого в файле db.js добавим следующее:

db.js
let taskList = []

export function getMyTasks() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(taskList)
        }, 500)
    })
}

export function addTask(text) {
    taskList.push(text)
}

export function deleteTask(id) {
    taskList.splice(id, 1)
}

На 15 строке создается и экспортируется функция для удаления, которая на вход принимает параметр id. В нем просто идет вызов метода splice(), который позволяет удалять элементы у массивов в JS.

После создания этого метода не забудьте импортировать его в файле app.js. Итоговый вариант должен выглядеть так:

app.js
import express from 'express'
import { PORT, TOKEN } from './config.js'
import Telegraf from 'telegraf'
import session from 'telegraf/session.js'
import { getMainMenu, yesNoKeyboard } from './keyboards.js'
import { getMyTasks, addTask, deleteTask } from './db.js'

const app = express()
const bot = new Telegraf(TOKEN)

bot.use(session())

bot.start(ctx => {
    ctx.replyWithHTML(
        'Приветсвую в <b>TaskManagerBot</b>\n\n'+
        'Чтобы быстро добавить задачу, просто напишите ее и отправьте боту',
        getMainMenu())
})

bot.hears('Мои задачи', async ctx => {
    const tasks = await getMyTasks()
    let result = ''

    for (let i = 0; i < tasks.length; i++) {
        result = result + `[${i+1}] ${tasks[i]}\n`
    }

    ctx.replyWithHTML(
        '<b>Список ваших задач:</b>\n\n'+
        `${result}`
    )
})

bot.hears('Удалить задачу', ctx => {
    ctx.replyWithHTML(
        'Введите фразу <i>"удалить `порядковый номер задачи`"</i>, чтобы удалить сообщение,'+
        'например, <b>"удалить 3"</b>:'
    )
})

bot.hears(/^удалить\s(\d+)$/, ctx => {
    const id = Number(+/\d+/.exec(ctx.message.text)) - 1
    deleteTask(id)
    ctx.reply('Ваша задача успешно удалена')
})

bot.hears('Смотивируй меня', ctx => {
    ctx.replyWithPhoto(
        'https://img2.goodfon.ru/wallpaper/nbig/7/ec/justdoit-dzhastduit-motivaciya.jpg',
        {
            caption: 'Не вздумай сдаваться!'
        }
    )
})

bot.on('text', ctx => {
    ctx.session.taskText = ctx.message.text

    ctx.replyWithHTML(
        `Вы действительно хотите добавить задачу:\n\n`+
        `<i>${ctx.message.text}</i>`,
        yesNoKeyboard()
    )
})

bot.action(['yes', 'no'], ctx => {
    if (ctx.callbackQuery.data === 'yes') {
        addTask(ctx.session.taskText)
        ctx.editMessageText('Ваша задача успешно добавлена')
    } else {
        ctx.deleteMessage()
    }
})

bot.launch()
app.listen(PORT, () => console.log(`My server is running on port ${PORT}`))

Заключение

Друзья! Могу вас поздравить с вполне рабочим ботом. Давайте подведем итог, что было сделано: Вы научились настраивать свое рабочее окружение (использовать ноду для своих JS проектов). Что касается ботов, то вы узнали про несколько способов обработки пользовательских действий. Научились создавать два типа клавиатур. Поработали с асинхронностью и регулярными выражениями. Также посмотрели, как можно отправить фотографии с описанием. Осталось, разве что только задеплоить бота на рабочий сервер, но об этом в другой статье.

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

Исходники бота на гитхабе.

Будем надеятся, что это не последняя моя статья. Если она оказалась для вас полезной или же остались какие-либо вопросы, вы всегда можете связаться со мной в телеграме arutemu_su.

Написано специально для телеграм канала Debug_Yourself. Можете подписаться на канал, ведь там я рассказываю про свой путь становления программистом. Делюсь информацией, которую изучаю, проектами, которые делаю, шишками, которые набиваю. Также иногда проскакивает диванная философия.

Last updated