К тебе в уютный и ламповый чат заходит непонятный и никому не известный человек. Ты приветствуешь его, но он угрюмо молчит. Только ты успеваешь мысленно оправдать его поведение, как он подло кидает рекламу очередной мошеннической криптовалютной группы. Знакомо? Сегодня мы закодим и запустим в продакшен собственное решение, которое положит конец гнусным спамерам!

Меня зовут Никита, и я программист: зарабатываю программированием на хлеб насущный и в свободное время пишу разного рода инструменты, которые упрощают жизнь пользователям интернета. Некоторые ты, возможно, используешь. Один из моих самых популярных продуктов — это @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 (точки входа) — чуть позже я расскажу зачем.

Таким образом я настроил свой проект
Таким образом я настроил свой проект

Создай файл 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. Если все было правильно, твой проект должен походить на следующий скрин.

Добавили шаг компиляции TS
Добавили шаг компиляции TS

Теперь перед каждым запуском $ 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()

Давай разберем код и посмотрим, что же он делает.

  1. Импортируем все зависимости, с которыми будем работать. Здесь важно отметить, что из-за неидеальности типов Telegraf нам приходится не только импортировать дополнительный тип ContextMessageUpdate, но и сам telegraf импортировать олдскульным require, поэтому нам приходится ручками прописывать тип у переменной bot. Обычно TypeScript угадывает типы автоматически.
  2. Импортируем все переменные из файла .env, чтобы можно было их использовать в формате process.env.*имя переменной*.
  3. Создаем объект самого бота, который и будет отвечать на наши сообщения, используя токен из .env.
  4. При помощи встроенного синтаксиса Telegraf мы говорим боту: когда получаешь сообщение текстом, ответь тем же текстом пользователю.
  5. Включаем бота, чтобы тот время от времени проверял сервер «Телеграма» на предмет новых сообщений.

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

Подобный бот у тебя должен был получиться
Подобный бот у тебя должен был получиться

Дальше я не стану приводить длинные выкладки кода: просто буду говорить, куда и что добавлять. Поэтому будь внимателен!

 

Робот-вышибала

Идея, которую мы реализуем, очень проста: когда человек заходит в чат, бот ему говорит, что у него 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 секунд назад. Если это так, то банит и убирает из списка кандидатов.

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

10 комментариев

  1. Аватар

    Kiku0

    07.11.2018 в 13:53

  2. Аватар

    MrFreeman

    07.11.2018 в 16:56

    Хакер медленно превращается в Хабр (

  3. Аватар

    AndrewA

    07.11.2018 в 20:09

    Статья написана высококвалифицированным специалистом с опытом запуска собственных проектов. Спасибо автору.

  4. Аватар

    robotobor

    08.11.2018 в 05:47

    Ок! только в цифровой океан не закинули

    • Аватар

      Никита Колмогоров

      08.11.2018 в 11:48

      Не успел отредактировать 🙁 может, напишу вторую часть, если редакция одобрит.

  5. Аватар

    avator888

    09.11.2018 в 00:59

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

    а куда это все деплоить и какой язык использовать для реализации — питон паскаль и не дай Бог js это уже дело сугубо личное

    короче — интересно но немного на хабр

  6. Аватар

    Stiff

    12.11.2018 в 01:31

    «и хранением записей в MongoDB, а после закинем его на Digital Ocean и настроим быстрый и бесплатный CI на основе простых веб-хуков GitHub»
    В статье этого нет.
    Оплатил подписку раде хорошего туториала, но в данной статье интеграционной составляющей вообще не описано.

  7. Аватар

    p2x4

    25.09.2020 в 13:25

Оставить мнение