Можете представить Новый год без мандаринов, елки и подарков? А что насчет фильма «Один дома»? Каждый год мы наблюдаем за судьбой мальчика, который забаррикадировался дома и обороняет его от двух бандитов. Эта история стала неотъемлемой частью каждого Нового года, и предстоящий праздник не будет исключением. А что, если мы предложим вам помочь Кевину в обороне дома?

Мы решили написать небольшую игру в жанре Interactive Fiction на базе телеграм-бота. Целевой аудиторией стали разработчики. Участники игры будут две недели общаться с Кевином и помогать ему программировать устройства в умном доме, чтобы разрушить планы грабителей. Для работы выбрали NestJS. Расскажу подробнее, что из этого получилось.

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

Не пишем бота за N минут

Написать телеграм-бота просто, если он небольшой. Часто такие боты пишут, чтобы автоматизировать рутинные процессы. Вся разработка ограничивается десятком простых команд. 

Перед нами стояла задача спроектировать более сложную архитектуру. Бот в лице мальчика Кевина общается с игроком и продвигается дальше по истории. В игре есть небольшие альтернативные ветки сюжета. Также игрок каждый день получает новые задачи, верное решение которых двигает его вверх по турнирной таблице. Бот должен уметь «уходить по своим делам» и возвращаться на следующий день в нужное время. 

Продукт планировался объемным, и важно было позаботиться об архитектуре приложения, о чем расскажу ниже. 

За такую задачку мы взялись впервые. Когда приступаешь к чему-то новому в разработке, обычно сначала идешь в интернет, чтобы изучить опыт других разработчиков: учиться всегда лучше на чужих ошибках. 

Мы нашли кучу статей с заголовками «Как написать телеграм-бота за 20/10/5 минут на Node.js», но ни одной про то, как лучше спроектировать разработку телеграм-бота под нашу задачу. Мы поняли, что в этот раз придется набивать свои шишки.

Какие технологии использовали

Бот — это серверное приложение. Телеграм давно позаботился об UI и подготовил API по его отрисовке. Нам достаточно отправить в Телеграм нужный запрос, чтобы пользователю пришло сообщение в виде текста, картинки, видео, аудио или кнопок. 

Наша задача — написать Node.js-приложение. Для этого будем использовать фреймворк NestJS. Разработчики фреймворка в своей философии говорят: «Хотя для Node.js и серверного JavaScript существует множество превосходных библиотек, помощников и инструментов, ни один из них не решает эффективно основную проблему — архитектуру». А NestJS — отличный инструмент по написанию масштабируемых серверных приложений. Если вы пишете и на Angular, то даже не заметите разницы. Создатели NestJS вдохновлялись концепциями этого TS-фреймворка.

Общаться с Telegram API решили через библиотеку nestjs-telegraf. Это удобная обертка под NestJS над популярным решением Telegraf.js. Telegraf.js предлагает интересные идеи и упрощает работу по сравнению с разработкой напрямую через Telegram API. Признаемся, что Telegraf.js крайне далек от идеала. Например, у него крайне скудная документация. Но это одно из лучших решений, какие были в открытом доступе.

Как писали диалоги с Кевином

Как выглядит базовый сценарий любого бота в Телеграме? Мы пишем множество обработчиков событий: что ответить на слово «привет», что отправить пользователю при нажатии на каждую из кнопок, чего ждать пользователю на команду /yes и так далее. Когда бот небольшой, то пара десятков таких обработчиков могут быть и бесконфликтны. Но по мере роста приложения они рано или поздно начнут конфликтовать.

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

Основное ядро игры — история, которую рассказывает мальчик Кевин. Главный герой задает игроку вопросы и активно спрашивает совета. Игрок отвечает с помощью предложенных кнопок. Благодаря этому создается иллюзия иммерсивного спектакля — игрок полностью вовлечен в процесс. Между Кевином и игроком строится длинный диалог. За всю игру в диалоге появятся сотни вопросов и кнопок.

История — основная, но не единственная часть. Еще в боте есть часть с ежедневными задачами и логикой по их проверке. Также у бота в самом начале есть часть с регистрацией пользователя, чтобы он стал участником общего соревнования, которое будет проходить среди игроков. В общем, всего много.

Реализовать такую логику силами базового Telegram API возможно, но спустя какое-то время разросшийся монстр из кода начнет сниться вам в кошмарах. К счастью, Telegraf.js предлагает интересное решение такой проблемы — сцены (scenes). 

Можно писать код модулями, отправляя пользователя в одну из сцен. Это как отправить пользователя в изолированную комнату с потрясающим шумоподавлением: он не будет слышать, что происходит в других комнатах. Вот со сценами из Telegraf так же: в сцене Story у игрока не будут срабатывать обработчики событий из сцены Task, и наоборот.

Технически сцена имеет точку входа и слушает набор событий, происходящих в комнате. Например:

import {Action, Ctx, Hears, Scene, SceneEnter} from 'nestjs-telegraf';
import {SceneContext} from 'telegraf/typings/scenes';
import {Update} from 'telegraf/typings/core/types/typegram';

@Scene('basicScene')
export class StoryScene {
   @SceneEnter()
   async enter(@Ctx() context: SceneContext) {
       context.reply('2+2 = ?', {
           reply_markup: {
               inline_keyboard: [
                   [{text: 'Может быть 4?', callback_data: '4'}],
                   [{text: 'Точно пять!', callback_data: '5'}],
               ],
           },
       });
   }

   @Action(/4|5/)
   async onAnswer(
     @Ctx() context: SceneContext & {update: Update.CallbackQueryUpdate}
   ) {
       const cbQuery = context.update.callback_query;
       const userAnswer = 'data' in cbQuery ? cbQuery.data : null;

       if (userAnswer === '4') {
           context.reply('верно!');
           context.scene.enter('nextSceneId');
       } else {
           context.reply('подумай еще');
       }
   }
}

Функция, помеченная декоратором @SceneEnter(), срабатывает каждый раз, когда мы входим в сцену. Функция, помеченная декоратором @Action(/4|5/), ждет, пока пользователь нажмет любую кнопку, у которой callback_data равна 4 или 5. Если пользователь дает верный ответ, бот перенаправляет его в следующую сцену. Вот и все, что нужно базово знать о сценах.

А как же написать длинный диалог между ботом и игроком? Это первая загадка, над которой мы ломали голову. Может, нужно написать тысячи сцен, каждая из которых будет последовательно отправлять игрока на следующий шаг? На первый взгляд, это кажется абсолютно рабочим, а главное, надежным решением. Но потом мы представили, сколько строчек кода нам придется написать, сколько boilerplate придется сотворить… И начали искать другое решение.

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

import {Action, Ctx, Scene} from 'nestjs-telegraf';
import {SceneContext} from 'telegraf/typings/scenes';

@Scene('exampleScene')
export class StoryScene {
   // ...
  
   @Action(/.*/)
   async onAnswer(@Ctx() context: SceneContext) {
       // ...

       await context.scene.reenter()
   }
}

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

Нужно разбить всю историю на части — шаги, а дальше перезаходить в сцену на каждый шаг. За шаг берем набор реплик бота с ожиданием ответа от игрока. Помещаем весь сценарий игры в огромный json, упрощенная структура которого выглядит так:

{
   "introduction": {
       "replies": [
           {"type": "text", "message": "Привет! Меня зовут Кевин.\n Мне 7 лет, и я застрял один дома"},
           {"type": "image", "src": "assets/sad-boy.png"}
       ],
       "buttons": [
           {"text": "Как ты оказался один?", "nextStep": "parents-go-away"},
           {"text": "А почему ты пишешь мне?", "nextStep": "found-you-contact"}
       ]
   },
   "parents-go-away": {
       "replies": [
           {"type": "voice", "src": "assets/parents-forgot-me.wav"},
       ],
       "buttons": [...]
   },
   "found-you-contact": {
       "replies": [
           {"type": "text", "message": "Нашел твой номер в записной книжке."},
           {"type": "text", "message": "Мне больше некому писать ((("},
       ],
       "buttons": [...]
   },
  ...
}

У этого решения есть ряд достоинств: 

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

  2. Не нужно в огромных объемах писать императивный код. Мы декларативно задаем всю структуру истории, а дальше пишем сцену, которая сумеет обработать структуру данного json-файла.

Пример минимальной структуры сцены Story:

import {Action, Ctx, Scene, SceneEnter} from 'nestjs-telegraf';
import {Context} from 'telegraf';
import {SceneContext} from 'telegraf/typings/scenes';
import {Update} from 'telegraf/typings/core/types/typegram';

/* just some service to save data in SQL database */
import {UserSessionStorageService} from '../../storages';

/* import json (and its interface) described above */
import {IStoryStep} from '../../types';
import notValidatedStoryJson from './story.json';

const storySteps: Record<string, IStoryStep> = notValidatedStoryJson;

const getUserId = (context: Context): number => {
   if ('callback_query' in context.update) {
       return context.update.callback_query.from.id;
   }

   if ('message' in context.update) {
       return context.update.message.from.id;
   }

   if ('my_chat_member' in context.update) {
       return context.update.my_chat_member.from.id;
   }

   return -1;
};

@Scene('story')
export class StoryScene {
   constructor(
     private readonly userSessionService: UserSessionStorageService
   ) {}

   @SceneEnter()
   async start(@Ctx() context: SceneContext) {
       const userId = getUserId(context);
       const currentStep = await this.userSessionService.getUserStoryStep(
         userId
       );
       const {buttons, replies} = storySteps[currentStep];

       // ... (send message or media) + buttons 
   }

   @Action(/.*/)
   async onAnswer(
     @Ctx() context: SceneContext & {update: Update.CallbackQueryUpdate}
   ) {
       const userId = getUserId(context);
       const cbQuery = context.update.callback_query;
       const nextStep = 'data' in cbQuery ? cbQuery.data : null;

       await this.userSessionService.updateUserSession(
         userId,
         {storyStep: nextStep}
       );
       await context.scene.reenter();
   }
}

Задачу, как написать длинный диалог, решили. Можно переходить к следующей волнующей теме — как запомнить положение игрока.

Как искали игрока

В статье было описано, насколько удобна и хороша концепция сцен в Telegraf.js. Но и она не без недостатков. Одна из критичных проблем — необходимость в безболезненных релизах. Даже если наивно предположить, что наше приложение никогда не упадет от нагрузок или неотловленных ошибок, рано или поздно придется перезагрузить наше NestJS-приложение при релизе. И бот забудет, в какой сцене находился игрок.

В дефолтной конфигурации сцен информация о том, в какой из сцен находится пользователь, сохраняется в in-memory-хранилище. Telegraf.js не предлагает никаких готовых решений по интеграции с базами данных для устранения этой проблемы. Но сообщество щедро делится с нами реализациями под разные БД.

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

Принцип работы сохранения мета-информации о сценах в любую базу данных:

import {Middleware} from 'telegraf';
import {SceneContext} from 'telegraf/typings/scenes';
import {getUserId} from '../utils';

const EMPTY_SESSION = {__scenes: {}};

class DatabaseManager {
   constructor(yourDbParams: Record<string, any>) {
       // ...
   }

   async getSession(userId: number): SceneContext['session'] {
       // get session from database by userId
       // ...
   }

   async saveSession(session: SceneContext['session'], userId: number) {
       // save current user's session
       // ...
   }
}

export function createPostgreSQLSession(
  yourDbParams: Record<string, any>
): Middleware<SceneContext> {
   const dbManager = new DatabaseManager(yourDbParams);

   return async (ctx, next) => {
       const id = getUserId(ctx);

       let session: SceneContext['session'] = EMPTY_SESSION;

       Object.defineProperty(ctx, 'session', {
           get: function () {
               return session;
           },
           set: function (newValue) {
               session = Object.assign({}, newValue);
           },
       });

       session = await dbManager.getSession(id) || EMPTY_SESSION;

       await next(); // wait all other middlewares
       await dbManager.saveSession(session, id);
   };
}

Мы написали Telegraf Middleware. Такая концепция практически не отличается от аналогичных терминов из Express и NestJS. Функция перехватывает запросы, получаемые приложением, для их модификации. Она принимает context, в котором хранится информация, в какой сцене находится пользователь, и асинхронную функцию next. Последний аргумент позволяет передать перехваченный запрос дальше — до следующих Middleware. Возвращаемый аргумент из функции Promise завершится, когда самый последний Middleware закончит свою работу. 

Сразу после завершения Promise из функции next() мы синхронизируем с БД новое состояние сессии. Почему же мы решили, что к завершению работы всех Middleware у нас будет актуальное состояние сессии с последней модификацией?  

Стоит добавить, что в Telegraf.js все является Middleware: вход в сцену, обработчик клика по кнопкам, отправка сообщения пользователем. Поэтому, дождавшись await next(), можно быть уверенным, что все события, способные привести к смене сессии пользователя, сработали.

Описанной информации должно быть достаточно, чтобы оценить, подходит ли выбранное решение сообщества под ваши условия. Или можно написать свое решение, если кейс специфичный.

Как завершить игровой день

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

Наверное, не стоит долго объяснять, почему такое решение — плохая затея:

const DAY_IN_MS = 24 * 60 * 60 * 1000;
setTimeout(() => context.scene.enter('nextDayScene'), DAY_IN_MS);

Ваш setTimeout просуществует только до первой перезагрузки приложения. К счастью, в NestJS уже есть решение с Cron Jobs, вся сложная логика которого удобно обернута в один декоратор @Cron(). Этот декоратор сам вызовет нужную функцию в указанное время. Например:

import {Cron, CronExpression} from '@nestjs/schedule';
 //…
    @Cron(CronExpression.EVERY_DAY_AT_NOON, {timeZone: 'Europe/Moscow'})
    handleDailyCron() {}

А внутрь функции handleDailyCron можно поместить всю логику по рассылке сообщений пользователям.

Примечание. Если работают несколько инстансов приложения с ботом, эта Сron Job сработает на каждом из них. И каждый пользователь получит несколько дублей одной и той же рассылки. В такой ситуации подойдет популярное решение Bull (NestJS также предоставляет верхнеуровневую обертку @nestjs/bull).

Инкапсулируем всю логику в простенький NestJS-сервис:

import {Injectable} from '@nestjs/common';
import {Cron, CronExpression} from '@nestjs/schedule';
import {Subject} from 'rxjs';

@Injectable()
export class DailyEventService extends Subject<void> {
    @Cron(CronExpression.EVERY_DAY_AT_NOON, {timeZone: 'Europe/Moscow'})
    async handleDailyCron() {
        this.next();
    }
}

Далее инжектим сервис в любую часть приложения и подписываемся на rxjs Subject. Если вам непонятно, почему мы наследуемся от Subject и что за магия происходит в коде, то прочтите статью нашего коллеги про observable-сервисы.

Владимир Потехин

Статья про Observable–сервисы

Attention! Лучше не запускать все сообщения за раз, а постараться распределять их в течение нескольких часов. В Телеграме нет официального метода по массовой рассылке писем всем подписчикам бота. В официальной документации есть строки с обещаниями добавить это в будущем. Если отправлять все сообщения традиционным способом, то рано или поздно наступит лимит и придут множественные ошибки с кодом 429. Мы проигнорировали это предостережение и надеялись, что ограничения частоты запросов через Bottleneck будет достаточным. За это поплатились в первый день игры — приложение упало. Проблему уже исправили, но неприятный осадок остался и для нас, и для игроков. Не будьте, как мы, будьте предусмотрительнее и тестируйте :)

Итог

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

Наш бот уже готов! Можно запускать и тестировать: https://t.me/kevin_codealone_bot

Надеемся, что сама идея будет интересна.

P. S. Сейчас мы каждый день получаем письма от участников игры, поэтому хотим поблагодарить за обратную связь и поддержку! Забираем все полученные косяки в проработку и make bot great again.

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