Меня зовут Никита, и я программист: зарабатываю программированием на хлеб насущный и в свободное время пишу разного рода инструменты, которые упрощают жизнь пользователям интернета. Некоторые ты, возможно, используешь. Один из моих самых популярных продуктов — это @voicybot, бесплатный бот для «Телеграма», который просто переводит аудиосообщения в текст. На момент написания статьи им пользуется более полумиллиона чатов, а одних только голосовых сообщений через него проходит более 40 тысяч в сутки.
На самом деле создать чат-бота для Telegram с имеющимися инструментами стало максимально просто, что я и собираюсь продемонстрировать в этой статье. Буквально за полчаса мы с тобой напишем достаточно сложного антиспам-бота на Node.js с использованием TypeScript 3 и хранением записей в MongoDB, а после закинем его на Digital Ocean и настроим быстрый и бесплатный CI на основе простых веб-хуков GitHub. Конечно, можно было бы развернуть все и на «Докере», но, думаю, новичкам в программировании сложнее разбираться с этой магией. Сегодня только олдскул!
Настройка окружения
Хоть в статье я и буду использовать мой верный «Мак», на других платформах все примерно так же, за исключением некоторых настроек и процесса установки программ и утилит. Если у тебя возникнут проблемы с установкой, можешь связаться со мной в Telegram — попробую помочь.
В качестве IDE (или текстового редактора, смотря какого лагеря этого холивара придерживаться) я воспользуюсь VSCode. Хранить код буду на GitHub. Скачай и установи себе VSCode, а затем зарегистрируйся на GitHub.
Первым делом стоит установить то, на чем мы будем писать проект, — Node.js. Можно стянуть инсталлятор c официального сайта, но я крайне рекомендую использовать NVM — менеджер версий для Node.js, который позволяет не только установить разные версии, но и переключаться между ними. Установить его можно из официального репозитория следующей командой:
$ curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.11/install.sh | bash
Советую использовать наиболее актуальную команду из репозитория Readme. Не забудь перезапустить терминал после установки NVM. После стоит установить сам Node.js. У меня одиннадцатая версия, но все последующие, в принципе, должны работать примерно так же. Установим последнюю версию «Ноды»:
$ nvm install node
Если после выполнения $ which node
терминал выдает папку, то все прошло успешно. Вместе с Node.js ставится и менеджер пакетов NPM, который мы заменим на более быстрый yarn
. Пропиши следующую команду, чтобы установить его:
$ npm install -g yarn
Без опций npm
и yarn
добавляют и устанавливают пакеты в открытую папку проекта. Однако если к npm
добавить флаг -g
, то пакет установится прямо в систему (на самом деле в пользователя — но углубляться не будем) и будет доступен как отдельное приложение. Таким образом, мы установили yarn
отдельным приложением и уже будем использовать его. Время заставить yarn
установить TypeScript 3 глобально! Делается это не флагом, а отдельным аргументом при вызове:
$ yarn global add typescript
Одна команда — и у тебя в системе теперь есть TypeScript. Вся мощь Unix на кончиках пальцев!
Последний кусочек пазла — это база данных MongoDB, которую мы и будем использовать для этого проекта. MongoDB — база данных типа NoSQL, что означает отсутствие очевидных связей между таблицами при помощи связных таблиц (или join tables), но зато дает упрощенную структуру данных в читабельном виде и простейшие миграции. Человеческим языком: эффективных связей между объектами быть не может, но разрабатывать проще.
Проще всего установить MongoDB с официального сайта. Разработчики будут всячески пытаться продать тебе собственное хранилище базы данных (БД) в облаке — не ведись на провокации. Все хранилища БД в облаках дешевые только до первых 10 тысяч пользователей, дальше тебя начнут разорять. Мы поднимем собственное облако для БД с блек-джеком и прочими атрибутами. Также есть и официальный туториал по установке MongoDB на «Мак» через терминал — именно это я и советую сделать. Заодно и homebrew
себе поставь, лишним точно не будет.
На этот момент у тебя должны быть установлены:
- Node.js;
- Yarn;
- TypeScript;
- MongoDB.
Если все установлено и работает, смело продолжай читать туториал. Если что-то не получилось, дальше будет слишком тяжко, лучше поправить сейчас.
Тест-драйв
Типичная вещь, с которой нужно начинать писать любой проект. Давай прохеллоуворлдим наш технологический стек (или его часть) до начала реального кодинга — чтобы понять, все ли работает.
Создай в любом месте у себя на компьютере (у меня есть удобная папочка ~/code
, где я держу все проекты) папку shieldy_bot
и зайди в нее в терминале. В папке проекта пропиши
$ yarn init
Эта команда задаст тебе ряд вопросов и создаст проект вместе с файлом package.json
— это тот самый мастер-файл, на который будет смотреть Node.js при запуске проекта. Обязательно укажи dist/index.js
в качестве entry point
(точки входа) — чуть позже я расскажу зачем.
Xakep #235. Возрождение эксплоит-китов
Создай файл index.js
в папке dist
в папке проекта. Внутри пропиши лишь одну строку: console.log('Hello world!')
. В файл package.json
добавь скрипт start
вида node dist/index.js
.
INFO
Здесь и далее: команды Unix должны выполняться в папке проекта, если не сказано иначе.
Вперед: запусти команду $ yarn start
, и ты должен увидеть Hello world!
в своей консоли. Если так и произошло — успех, Node.js работает! Теперь проверим TypeScript. Но сначала установим его прямо в проект.
$ yarn add typescript
После добавления TypeScript прямо в проект будет использоваться именно он, а не тот, что установлен в системе. TypeScript не запускается напрямую «Нодой», он сначала компилируется в JavaScript, а потом компилированный код и запускается при помощи Node.js. В нашем проекте мы будем хранить и писать наш код TypeScript в папке src
, а компилироваться и запускаться JavaScript будет в папке dist
. Чтобы tsc
(тулза — компилятор из TypeScript в JavaScript) работала правильно, ее нужно настроить. Добавь в корень проекта файл tsconfig.json
со следующим содержанием:
{
"compilerOptions": {
"module": "commonjs",
"target": "es2017",
"lib": ["es2015"],
"moduleResolution": "node",
"sourceMap": true,
"outDir": "dist",
"baseUrl": ".",
"paths": {
"*": ["node_modules/*", "src/types/*"]
}
},
"include": ["src/**/*"]
}
Вдаваться в подробности, что это за файл и как он работает, я не буду: это задача вне текущего руководства. Стоит лишь отметить, что мы берем TypeScript-файлы из src
, конвертируем в стандарт es2017
и кладем в папочку dist
.
После удаляем папку dist
— она теперь должна генерироваться сама. Добавляем папку src
и кладем туда уже index.ts
с содержанием console.log('Жизнь за Харамби')
(ts
— это расширение файлов TypeScript). В принципе, любой рабочий JavaScript (JS) — это еще и рабочий TypeScript (TS), так как TS — это надстройка над JS.
Также стоит отметить, что TS — это типизированный язык, в отличие от JS. Но так как множество пакетов было изначально написано на JS, сообщество начало дополнять уже существующие проекты информацией о типах в репозитории Definitely Typed, откуда все разработчики заимствуют типы. Так и мы сделаем для «Ноды» — выполним команду $ yarn add @types/node
. Если все было правильно, твой проект должен походить на следующий скрин.
Теперь перед каждым запуском $ yarn start
тебе нужно запускать команду $ tsc
, чтобы компилировать код TS в JS. К слову, можно еще и использовать $ tsc -w
. Флаг -w
заставит tsc
перекомпилировать файлы, которые изменяются, при их сохранении. Удобно! Но еще удобнее будет поменять команду start
из package.json
на tsc && node dist/index.js
— тогда каждый раз при запуске $ yarn start
будет запускаться и tsc
.
Запусти $ yarn start
, предварительно добавив или запустив $ tsc
вручную. Ты должен увидеть выплюнутую консолью строку «Жизнь за Харамби».
Если все получилось, то поздравляю: ты настроил окружение для разработки и теперь знаешь, что TypeScript работает. Теперь настроим контроль версий!
Git
Наш механизм Continuous Integration (CI) будет основан на веб-хуках GitHub. Запушил в репозиторий — и код сразу же развернулся на сервере в продакшене. Поэтому никак нельзя обойти стороной контроль версий.
Любой контроль версий начинается с правильного игнорирования файлов. Добавь в корне проекта файл с названием .gitignore
и следующим содержанием:
node_modules
dist
.env
Это позволит избежать коммитов с пакетами из NPM (в идеале они должны устанавливаться теми, кто качает репозиторий с кодом), скомпилированным кодом (он должен компилироваться тем, кто качает репозиторий) и переменными окружения, куда мы добавим разного рода информацию, наподобие местонахождения нашей БД. Информация из .env
не должна ни в коем случае попасть на GitHub, а в зависимости от окружения (разработка, тестовый или боевой сервер) должна отличаться.
Теперь инициализируем репозиторий Git, добавим все файлы в систему контроля версий и закоммитим их следующими командами:
$ git init
$ git add .
$ git commit -m "Initial commit"
Думаю, на этом с контролем версий и закончим — главное, не забывай, что в случае пожара делаем add
, commit
, push
. Также стоит отметить, что, хоть к «Гиту» мы вернемся только ближе к концу статьи, смело делай commit
и push
на свое усмотрение.
Пишем бота-автоответчик
Для начала нужно убедиться, что мы можем написать простейшего бота, — чтобы после продолжить с более сложным концептом. Первый шаг создания любого бота для «Телеграма» — это общение с @botfather — отцом всех ботов. После пары-тройки несложных манипуляций (уверен, ты справишься) ты получишь токен бота в следующем виде:
771096498:AAHZOrCZZpdDu1boI6hwf_m3PSYWXKK660M
Если что, этот токен уже не актуален, так что все же сделай свой. Мы его добавим в файл .env
, который теперь должен выглядеть так:
TOKEN=771096498:AAHZOrCZZpdDu1boI6hwf_m3PSYWXKK660M
.env
— это файл для переменных окружения. Так как мы добавили его в gitignore
, он не только не будет загружен на GitHub, но еще и не будет доступен людям и сервисам, которые захотят клонировать репозиторий. Более того, контроль версий его вообще видеть не будет, так что на разных серверах (и даже просто в разных папках) ты всегда сможешь указать разные файлы .env
. Например, для тестового сервера и боевого.
Чтобы заполучить доступ к переменным из этого файла, нам нужно добавить свой первый пакет зависимостей. Пропиши в терминале следующее:
$ yarn add dotenv @types/dotenv
Теперь у тебя в проекте установлен модуль dotenv
с его типами. Стоит отметить, что сразу несколько пакетов можно устанавливать, указав их через пробел команде $ yarn add
. Также типы из Definitely Typed не обязательно ставить для каждого пакета зависимостей — велика вероятность, что пакеты уже будут идти со своими типами, прописанными разработчиками. Все-таки TypeScript уже давно на рынке и успел понравиться многим.
Перепишем наш index.ts
следующим образом:
// Зависимости
import * as dotenv from 'dotenv'
// Настраиваем dotenv
dotenv.config({ path: `${__dirname}/../.env` })
// Принтим токен
console.log(process.env.TOKEN)
INFO
Здесь и далее я подразумеваю, что ты запускаешь и тестируешь свой код, сначала скомпилировав его при помощи команды $ tsc
, а потом запустив с $ yarn start
.
После запуска твоя консоль должна выдать такую строчку:
771096498:AAHZOrCZZpdDu1boI6hwf_m3PSYWXKK660M
Если это так, то все в ажуре — переходим к непосредственному написанию бота. Использовать мы будем библиотеку Telegraf, она на данный момент самая продвинутая. Поставим ее командой $ yarn add telegraf
— типы ставить не нужно, они идут в пакете с основным кодом.
Изменим наш index.ts
на что-то такое:
// Зависимости
import * as dotenv from 'dotenv'
import { Telegraf, ContextMessageUpdate } from 'telegraf'
const telegraf = require('telegraf')
// Настраиваем dotenv
dotenv.config({ path: `${__dirname}/../.env` })
// Создаем бота
const bot: Telegraf<ContextMessageUpdate> = new telegraf(process.env.TOKEN)
// Отвечаем тем же текстом, что и был прислан
bot.on('text', ctx => {
ctx.reply(ctx.message.text)
})
// Включаем бота
bot.startPolling()
Давай разберем код и посмотрим, что же он делает.
- Импортируем все зависимости, с которыми будем работать. Здесь важно отметить, что из-за неидеальности типов Telegraf нам приходится не только импортировать дополнительный тип
ContextMessageUpdate
, но и самtelegraf
импортировать олдскульнымrequire
, поэтому нам приходится ручками прописывать тип у переменнойbot
. Обычно TypeScript угадывает типы автоматически. - Импортируем все переменные из файла
.env
, чтобы можно было их использовать в форматеprocess.env.*имя переменной*
. - Создаем объект самого бота, который и будет отвечать на наши сообщения, используя токен из
.env
. - При помощи встроенного синтаксиса Telegraf мы говорим боту: когда получаешь сообщение текстом, ответь тем же текстом пользователю.
- Включаем бота, чтобы тот время от времени проверял сервер «Телеграма» на предмет новых сообщений.
Запустив этот код, ты получишь рабочего бота, который умеет… пока что только передразнивать пользователя.
Дальше я не стану приводить длинные выкладки кода: просто буду говорить, куда и что добавлять. Поэтому будь внимателен!
Робот-вышибала
Идея, которую мы реализуем, очень проста: когда человек заходит в чат, бот ему говорит, что у него 60 секунд, чтобы что угодно написать в чат — хоть стикер, хоть команду, хоть фоточку. Если человек в течение минуты ничего не прислал, то мы кикаем его из группы.
Пока что не будем заморачиваться с перезапуском бота и сохранением состояний, просто будем держать список кандидатов на бан в памяти, а каждую секунду проверять, не нужно ли кого-то забанить.
Где-нибудь вверху добавь переменную chats
со следующей сигнатурой:
const chats = {} as {
[index: number]: { id: number; time: number }[]
}
Эта басурманская вязь — объявление типа объекта до его непосредственного использования. У нас будет объект идентификаторов чатов к списку кандидатов на бан в этом чате. У каждого кандидата на бан будет идентификатор от «Телеграма» и время, когда он зашел. Конечно, чище будет использовать интерфейсы — но это позже.
Теперь дело в шляпе — нужно просто смотреть, как люди заходят в чат, добавлять их в список кандидатов на бан, убирать, если будет замечена их активность в этом чате, и банить кандидатов-молчунов. Не забудь убрать блок кода, который заставляет бота отвечать тем же сообщением пользователям, и допиши следующие команды:
// Добавляем в кандидаты по приходу
bot.on('new_chat_members', ctx => {
for (const member of ctx.message.new_chat_members) {
addCandidate(ctx.chat.id, member.id)
ctx.reply('Пожалуйста, пришлите любое сообщение в этот чат в течение 60 секунд, или вы будете забанены')
}
})
// Удаляем из кандидатов по активности
bot.on('message', ctx => {
removeCandidate(ctx.chat.id, ctx.from.id)
})
Объект ctx
(от слова context) — это очень важная штука, в которой есть вся значимая для тебя информация. Нам нужен список новоприбывших, идентификатор чата и идентификатор пользователя, который прислал сообщение. Как ты уже мог догадаться, мы добавляем имплементацию функций addCandidate
и removeCandidate
.
// Добавляем кандидата
function addCandidate(chatId, candidateId) {
const candidate = {
id: candidateId,
time: new Date().getTime(),
}
let candidates = chats[chatId]
if (candidates) {
candidates.push(candidate)
} else {
candidates = [candidate]
}
chats[chatId] = candidates
}
// Убираем кандидата
function removeCandidate(chatId, candidateId) {
let candidates = chats[chatId]
if (!candidates) return
chats[chatId] = candidates.filter(candidate => candidate.id !== candidateId)
}
В принципе, здесь все просто — как только человек приходит в чат, мы создаем ему объект кандидата с его идентификатором и временем, когда он пришел. После мы либо достаем существующий массив кандидатов для определенного чата и добавляем туда нашего кандидата, либо создаем новый массив сразу же с кандидатом — а после возвращаем новый массив в объект идентификаторов чата к кандидатам.
Убирать кандидатов еще проще — мы получаем список кандидатов для чата. Если его нет — все хорошо, кандидата и убирать не надо. Если есть, то мы фильтруем массив кандидатов.
После нам остается лишь добавить таймер, который раз в секунду будет проверять, не нужно ли кого-нибудь снова забанить. Добавляем следующий код:
// Проверяем, нужно ли банить
setInterval(() => {
for (const chatId in chats) {
const candidates = chats[chatId]
if (!candidates.length) continue
const now = new Date().getTime()
for (const candidate of candidates) {
if (candidate.time < now - 60 * 1000) {
const telegram = bot.telegram as any
telegram.kickChatMember(chatId, candidate.id)
removeCandidate(chatId, candidate.id)
}
}
}
}, 1000)
Каждую секунду мы запускаем эту функцию. Она проходится по всем идентификаторам чатов из нашего объекта чатов с кандидатами. Если кандидатов нет, то просто пропускает этот чат. Иначе — проходится по всем кандидатам и сверяет, не зашел ли кто-то из них более 60 секунд назад. Если это так, то банит и убирает из списка кандидатов.
Вот и все — можешь запускать бота, добавлять его в любой чат с админскими правами и забыть про спамеров!