Pull to refresh

Бот для сборов. Собираемся на футбол с новыми технологиями

Reading time 21 min
Views 11K

Введение


Всем привет. В этой статье я опишу своего чат-бота для сервиса обмена сообщениями telegram и социальной сети VK с использованием NodeJS.


На этом месте у многих читателей должно вырваться что-то вроде: "Доколе!" или "Что, опять ?!".
Да, похожие публикации уже были и на хабре в том числе. Но, тем не менее я считаю, что статья будет полезна. Кратко о том что с технической стороны представляет реализация бота:


  1. В качестве каркаса для приложения используется набирающий популярность фреймворк NestJS.
  2. Библиотека telegraf для взаимодействия с API Telegram.
  3. Библиотека node-vk-bot-api для взаимодействия с API VK.
  4. Библиотека typeorm для организации слоя хранения данных.
  5. Тесты с использованием mocha и библиотеки ассертов chai .
  6. CI с использованием Travis CI для тестирования и GitHub Actions для деплоя докер-образов.

В качестве побочного задания попробуем подружить нашего бота с Viber делая его таким образом универсальным для использования в нескольких сервисах обмена сообщениями.


Тем кто хочет узнать что из этого получилось добро пожаловать под кат.


Постановка задачи


В качестве полезной физической нагрузки я предпочитаю футбол. На любительском уровне разумеется. Являясь участником нескольких каналов и чатов в vk, viber и telegram часто приходится видеть такую картину:


image


Что мы здесь видим:


  1. "П" добавил себя.
  2. После него добавилось еще 3 человека среди которых был некто "С".
  3. "П" добавил своего товарища по имени "Димон".
  4. После этого "П" не поленился и подсчитал что на текущий момент уже есть аж 5 человек.
  5. Однако эта информация была актуальной недолго, так как я тоже решил побегать и добавил себя.

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


  1. Вася поставил +1 имея ввиду не только себя, но и своего друга Гошу.
  2. Коля написал что прийдет, но организатор Спиридон посчитал глазами только плюсики.

В итоге имеем возможно неравные по кол-ву команды, "лишнего Гошу" и неожиданно возникшего Колю.


Для того, чтобы избежать таких коллизий я написал простого бота, который помогает наглядно отображать состав участников предстоящей игры и удобно в нее добавлять(ся)/удалять(ся).


Итак, в базовой реализации по моему мнению бот должен был уметь:


  1. Быть встраиваемым в произвольное кол-во чатов. Хранить свое состояние для каждого чата в отдельности.
  2. По команде /event_add создавать новое активное событие с определенной информацией и пустым списком игроков. Предыдущее активное событие должно становится неактивным.
  3. Команда /event_remove должна отменять текущее активное событие.
  4. Команда /add должна добавлять нового участника для текущего активного события. При этом если команда вызвана без какого-либо дополнительного текста, то добавляться должен тот, кто набрал команду. В противном случае добавляется человек чье имя указали при вызове команды.
  5. Команда /remove удаляет человека из списка участников по правилам, описанным для команды /add.
  6. Команда /info позволяет посмотреть кто уже записался на игру.
  7. Очень желательно, чтобы бот отвечал на том же языке который настроен у игрока (или на английском по умолчанию).

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


Проектирование и разработка


При проектировании приложения в голове возникла примерно следующая схема:


image


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


Интерфейс и модели сообщений


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


export interface IMessage {
    chatId: number;
    lang: string;
    text: string;
    fullText: string;
    command: string;
    name: string;
    getReplyStatus: () => string;
    getReplyData: () => any;
    setStatus: (status: string) => IMessage;
    withData: (data: any) => IMessage;
    answer: (args: any) => string | void;
}

Базовый класс BaseMessage, реализующий данный интерфейс имеет следующий вид:


BaseMessage
import {IMessage} from '../message/i-message';

export class BaseMessage implements IMessage {
    public chatId: number;
    public lang: string;
    public text: string;
    public fullText: string;
    public command: string;

    protected firstName: string;
    protected lastName: string;

    protected replyStatus: string;
    protected replyData: any;

    get name(): string {
        const firstName: string = this.firstName || '';
        const lastName: string = this.lastName || '';

        return `${firstName} ${lastName}`.trim();
    }

    public getReplyStatus(): string {
        return this.replyStatus;
    }

    public getReplyData(): any {
        return this.replyData;
    }

    public setStatus(status: string): IMessage {
        this.replyStatus = status;
        return this;
    }

    public withData(data: any): IMessage {
        this.replyData = data;
        return this;
    }

    public answer(args: any): string | void {
        throw new Error('not implemented');
    }
}
}

Класс сообщения для Telegram:


TelegramMessage
import {BaseMessage} from '../message/base.message';
import {IMessage} from '../message/i-message';

export class TelegramMessage extends BaseMessage implements IMessage {
    private ctx: any;

    constructor(ctx) {
        super();

        this.ctx = ctx;

        const {message} = this.ctx.update;
        this.chatId = message.chat.id;
        this.fullText = message.text;
        this.command = this.ctx.command;
        this.text = this.fullText.replace(`/${this.command}`, '');
        this.lang = message.from.language_code;
        this.firstName = message.from.first_name;
        this.lastName = message.from.last_name;
    }

    public answer(args: any): string | void {
        return this.ctx.replyWithHTML(args);
    }
}

и для VK:


VKMessage
import {BaseMessage} from '../message/base.message';
import {IMessage} from '../message/i-message';

export class VKMessage extends BaseMessage implements IMessage {
    private ctx: any;

    constructor(ctx) {
        super();

        this.ctx = ctx;

        const {message} = this.ctx;
        this.chatId = this.getChatId(this.ctx);
        this.fullText = message.text;
        this.command = this.ctx.command;
        this.text = this.fullText.replace(`/${this.command}`, '');
        this.lang = 'ru';
        this.firstName = message.from.first_name;
        this.lastName = message.from.last_name;
    }

    public answer(args: any) {
        const answer: string = `${args}`.replace(/<\/?(strong|i)>/gm, '');
        this.ctx.reply(answer);
    }

    private getChatId({message, bot}): number {
        const peerId: number = +`${message.peer_id}`.replace(/[0-9]0+/, '');
        const groupId: number = bot.settings.group_id;
        return peerId + groupId;
    }
}

На что здесь стоит обратить внимание:


  1. chatId — уникальный идентификатор чата. Для Telegram он приходит в явном виде в структуре каждого сообщения. Однако в случае VK такого идентификатора в явном виде нет. Как же быть? Чтобы ответить на этот вопрос нужно понять как бот функционирует в рамках VK. В этой системе бот в групповой переписке выступает от лица сообщества для которого он заводится. Т.е. каждое сообщение бота содержит числовой идентификатор сообщества group_id. Кроме того, в сообщении приходит peer_id (более подробно можно почитать здесь) как идентификатор групповой беседы в нашем случае. На основании этих двух идентификатором можно построить свой идентификатор чата например таким образом:


    private getChatId({message, bot}): number {
        const peerId: number = +(`${message.peer_id}`.replace(/[0-9]0+/, ''));
        const groupId: number = bot.settings.group_id;
        return peerId + groupId;
    }

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


  2. fullText и text. Как следует из названия переменных, fullText содержит полный текст сообщения, в то время как text содержит только ту часть, которая идет после названия команды.



Это удобно, так как позволяет использовать готовое поле text для парсинга содержащейся в нем вспомогательной информации вроде даты события или имени игрока.


  1. В отличие от Telegram сообщения VK не поддерживают набор тегов для выделения текста вроде <b> или <i>, поэтому при ответе данные тэги приходится удалять с помощью использования регулярных выражений.

Адаптеры для приема и отправки сообщений


После создания интерфейса и структур данных для сообщений Telegram и VK пришло время для реализации сервисов обработки входящих событий.


image


Эти сервисы должны инициализировать сторонние модули для взаимодействия с API Telegram и VK запускать механизмы long-polling, а также иметь связь входящих команд и внутренних событий возникающих в системе при получении данных от сервисов обмена сообщениями.


TelegramService
import Telegraf from 'telegraf';

import {Injectable} from '@nestjs/common';
import * as SocksAgent from 'socks5-https-client/lib/Agent';
import {ConfigService} from '../common/config.service';
import {AppEmitter} from '../common/event-bus.service';
import {TelegramMessage} from './telegram.message';

@Injectable()
export class TelegramService {
    private bot: Telegraf<any>;

    constructor(config: ConfigService, appEmitter: AppEmitter) {
        const botToken: string = config.get('TELEGRAM_BOT_TOKEN');

        this.bot = config.get('TELEGRAM_USE_PROXY')
            ? new Telegraf(botToken, {
                  telegram: {agent: this.getProxy(config)},
              })
            : new Telegraf(botToken);

        this.getCommandEventMapping(appEmitter).forEach(([command, event]) => {
            this.bot.command(command, ctx => {
                ctx.command = command;
                appEmitter.emit(event, new TelegramMessage(ctx));
            });
        });
    }

    public launch(): void {
        this.bot.launch();
    }

    private getProxy(config: ConfigService): SocksAgent {
        return new SocksAgent({
            socksHost: config.get('TELEGRAM_PROXY_HOST'),
            socksPort: config.get('TELEGRAM_PROXY_PORT'),
            socksUsername: config.get('TELEGRAM_PROXY_LOGIN'),
            socksPassword: config.get('TELEGRAM_PROXY_PASSWORD'),
        });
    }

    private getCommandEventMapping(
        appEmitter: AppEmitter,
    ): Array<[string, string]> {
        return [
            ['event_add', appEmitter.EVENT_ADD],
            ['event_remove', appEmitter.EVENT_REMOVE],
            ['info', appEmitter.EVENT_INFO],
            ['add', appEmitter.PLAYER_ADD],
            ['remove', appEmitter.PLAYER_REMOVE],
        ];
    }
}

VKService
import * as VkBot from 'node-vk-bot-api';

import {Injectable} from '@nestjs/common';
import {ConfigService} from '../common/config.service';
import {AppEmitter} from '../common/event-bus.service';
import {VKMessage} from './vk.message';

@Injectable()
export class VKService {
    private bot: VkBot<any>;

    constructor(config: ConfigService, appEmitter: AppEmitter) {
        const botToken: string = config.get('VK_TOKEN');

        this.bot = new VkBot(botToken);

        this.getCommandEventMapping(appEmitter).forEach(([command, event]) => {
            this.bot.command(`/${command}`, async ctx => {
                const [from] = await this.bot.execute('users.get', {
                    user_ids: ctx.message.from_id,
                });
                ctx.message.from = from;
                ctx.command = command;
                appEmitter.emit(event, new VKMessage(ctx));
            });
        });
    }

    public launch(): void {
        this.bot.startPolling();
    }

    private getCommandEventMapping(
        appEmitter: AppEmitter,
    ): Array<[string, string]> {
        return [
            ['event_add', appEmitter.EVENT_ADD],
            ['event_remove', appEmitter.EVENT_REMOVE],
            ['info', appEmitter.EVENT_INFO],
            ['add', appEmitter.PLAYER_ADD],
            ['remove', appEmitter.PLAYER_REMOVE],
        ];
    }
}

На что здесь стоит обратить внимание:


  1. Из-за определенных проблем с доступом к сервису Telegram (привет Роскомнадзор) пришлось опционально использовать прокси который предоставляет пакет socks5-https-client.
  2. При обработке события в контекст добавляется поле со значением команды с текстом которой было принято сообщение.
  3. Для VK приходится отдельно загружать данные пользователя который отправил сообщение с помощью отдельного API вызова:
    const [from] = await this.bot.execute('users.get', {
        user_ids: ctx.message.from_id,
    });

Модель данных


Для реализации заявленной функциональности бота было достаточно наличия трех моделей:


  1. Chat — чат или беседа.
  2. Event — событие для чате назначенное на определенную дату и время.
  3. Player — участник или игрок принимающий участие в событии.

Схема данных


Реализации моделей с помощью библиотеки typeorm имеют вид:


Chat
import {
    Entity,
    PrimaryGeneratedColumn,
    Column,
    OneToMany,
    JoinColumn,
    Index,
} from 'typeorm';
import {Event} from './event';

@Entity()
export class Chat {
    @PrimaryGeneratedColumn()
    id: number;

    @Index({unique: true})
    @Column()
    chatId: number;

    @OneToMany(type => Event, event => event.chat)
    @JoinColumn()
    events: Event[];
}

Event
import {
    Entity,
    PrimaryGeneratedColumn,
    Column,
    OneToMany,
    ManyToOne,
    JoinColumn,
} from 'typeorm';
import {Chat} from './chat';
import {Player} from './player';

@Entity()
export class Event {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    date: Date;

    @Column()
    active: boolean;

    @ManyToOne(type => Chat, chat => chat.events)
    chat: Chat;

    @OneToMany(type => Player, player => player.event)
    @JoinColumn()
    players: Player[];
}

Player
import {
    Entity,
    PrimaryGeneratedColumn,
    Column,
    ManyToOne,
    JoinColumn,
} from 'typeorm';
import {Event} from './event';

@Entity()
export class Player {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    name: string;

    @ManyToOne(type => Event, event => event.players)
    @JoinColumn()
    event: Event;
}

Здесь нужно еще раз проговорить механизм работы бота для различных команд с точки зрения манипуляции с моделями Chat, Event и Player.


  1. При получении любой команды происходит проверка существования в БД чата (Chat) с chatId. Если соответствующей записи нет, то она создается.
  2. Для упрощения логики в каждом чате может быть только одно активное событие (Event). Т.е. при получении команды /event_add в системе создается новое активное событие с указанной датой.
    Текущее активное событие (если оно существует) становится неактивным.
  3. /event_remove находит в текущем чате активное событие и деактивирует его.
  4. /info находит в текущем чате активное событие, выводит информацию по этому событию и список игроков (Player).
  5. /add и /remove работают с активным событием добавляя и удаляя из него игроков.

Соответствующий сервис для работы с данными получился следующим:


StorageService
import {Injectable} from '@nestjs/common';
import {InjectConnection} from '@nestjs/typeorm';
import {Connection, Repository, UpdateResult} from 'typeorm';

import {Chat} from './models/chat';
import {Event} from './models/event';
import {Player} from './models/player';

@Injectable()
export class StorageService {
    private chatRepository: Repository<Chat>;
    private eventRepository: Repository<Event>;
    private playerRepository: Repository<Player>;

    constructor(@InjectConnection() private readonly dbConnection: Connection) {
        this.chatRepository = this.dbConnection.getRepository(Chat);
        this.eventRepository = this.dbConnection.getRepository(Event);
        this.playerRepository = this.dbConnection.getRepository(Player);
    }

    public get connection() {
        return this.dbConnection;
    }

    public async ensureChat(chatId: number): Promise<Chat> {
        let chat: Chat = await this.chatRepository.findOne({chatId});

        if (chat) {
            return chat;
        }

        chat = new Chat();
        chat.chatId = chatId;

        return this.chatRepository.save(chat);
    }

    public markChatEventsInactive(chat: Chat): Promise<UpdateResult> {
        return this.dbConnection
            .createQueryBuilder()
            .update(Event)
            .set({active: false})
            .where({chat})
            .execute();
    }

    public appendChatActiveEvent(chat: Chat, date: Date): Promise<Event> {
        const event: Event = new Event();
        event.chat = chat;
        event.active = true;
        event.date = date;

        return this.eventRepository.save(event);
    }

    public findChatActiveEvent(chat: Chat): Promise<Event | null> {
        return this.eventRepository.findOne({where: {chat, active: true}});
    }

   public getPlayers(event: Event): Promise<Player[]> {
        return this.playerRepository.find({where: {event}});
    }

    public findPlayer(event: Event, name: string): Promise<Player | null> {
        return this.playerRepository.findOne({where: {event, name}});
    }

    public addPlayer(event: Event, name: string): Promise<Player> {
        const player: Player = new Player();
        player.event = event;
        player.name = name;

        return this.playerRepository.save(player);
    }

    public removePlayer(player: Player): Promise<Player> {
        return this.playerRepository.remove(player);
    }
}

Шаблонизация ответов


Помимо описанного сервиса StorageService работы с данными также возникла необходимость в реализации выделенного сервис TemplateService задачами которого на данный момент являются:


  1. Загрузка и компиляция handlebars шаблонов для вариантов ответов на различные команды на разных языках.
  2. Выбор подходящего шаблона в зависимости от текущего события, статуса ответа и языка пользователя.
  3. Заполнения шаблона данными полученными в результате выполнения команды.

TemplateService
import * as path from 'path';
import * as fs from 'fs';
import * as handlebars from 'handlebars';
import {readDirDeepSync} from 'read-dir-deep';
import {Injectable, Logger} from '@nestjs/common';

export interface IParams {
    action: string;
    status: string;
    lang?: string;
}

@Injectable()
export class TemplateService {
    private readonly DEFAULT_LANG: string = 'en';
    private readonly TEMPLATE_PATH: string = 'templates';

    private logger: Logger;
    private templatesMap: Map<string, (d: any) => string>;

    constructor(logger: Logger) {
        this.logger = logger;

        this.load();
    }

    public apply(params: IParams, data: any): string {
        this.logger.log(
            `apply template: ${params.action} ${params.status} ${params.lang}`,
        );

        let template = this.getTemplate(params);

        if (!template) {
            params.lang = this.DEFAULT_LANG;
            template = this.getTemplate(params);
        }

        if (!template) {
            throw new Error('template-not-found');
        }

        return template(data);
    }

    private getTemplate(params: IParams): (data: any) => string {
        const {lang, action, status} = params;
        return this.templatesMap.get(this.getTemplateKey(lang, action, status));
    }

    private load() {
        const templatesDir: string = path.join(
            process.cwd(),
            this.TEMPLATE_PATH,
        );
        const templateFileNames: string[] = readDirDeepSync(templatesDir);

        this.templatesMap = templateFileNames.reduce((acc, fileName) => {
            const template = fs.readFileSync(fileName, {encoding: 'utf-8'});

            const [, lang, action, status] = fileName
                .replace(/\.hbs$/, '')
                .split('/');
            return acc.set(
                this.getTemplateKey(lang, action, status),
                handlebars.compile(template),
            );
        }, new Map());
    }

    private getTemplateKey(
        lang: string,
        action: string,
        status: string,
    ): string {
        return `${lang}-${action}-${status}`;
    }
}

На что здесь стоит обратить внимание:


  1. Файлы шаблонов лежат в отдельной директории ./templates в корне проекта и структурированы по языку, действию (команде) и статусу ответа.


    - templates
     - en
         - event_add
            - invalid_date.hbs
            - success.hbs
         - event_info
     - ru

    При инициализации приложения все шаблоны ответов загружаются в память и заполняют Map с ключами которые однозначно идентифицируют шаблон:


    private getTemplateKey(lang: string, action: string, status: string): string {
      return `${lang}-${action}-${status}`;
    }

  2. В настоящий момент в системе есть 2 набора шаблонов: для русского и английского языков.
    Предусмотрен fallback на дефолтный язык (английский) в случае отсутствия необходимого шаблона для языка текущего события.


  3. Сами handlebars шаблоны, например:


    Player <strong>{{name}}</strong> will take part in the game 
    List of players:
    {{#each players}}
    {{index}}: <i>{{name}}</i>
    {{/each}}
    Total: <strong>{{total}}</strong>

    содержат как традиционные placeholder-ы, так и набор допустимых тегов для форматирования ответов в Telegram.



Сервисы обработки команд (Actions)


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


actions


В задачи базового класса BaseAction входит:


  1. Подписка на определенное событие от AppEmitter.
  2. Общая функциональность обработки событий, а именно:
    • поиск существующего или создание нового чата в контексте которого будет обработана команда.
    • Вызов шаблонного метода doAction реализация которого является индивидуальной для каждого класса наследующего BaseAction.
    • Применение шаблонов к полученному в результате выполнения doAction ответу с помощью TemplateService.

BaseAction
import {Injectable, Logger} from '@nestjs/common';
import {IMessage} from '../message/i-message';
import {ConfigService} from '../common/config.service';
import {AppEmitter} from '../common/event-bus.service';
import {TemplateService} from '../common/template.service';
import {StorageService} from '../storage/storage.service';
import {Chat} from '../storage/models/chat';

@Injectable()
export class BaseAction {
    protected appEmitter: AppEmitter;
    protected config: ConfigService;
    protected logger: Logger;

    protected templateService: TemplateService;
    protected storageService: StorageService;

    protected event: string;

    constructor(
        config: ConfigService,
        appEmitter: AppEmitter,
        logger: Logger,
        templateService: TemplateService,
        storageService: StorageService,
    ) {
        this.config = config;
        this.logger = logger;

        this.appEmitter = appEmitter;
        this.templateService = templateService;
        this.storageService = storageService;

        this.setEvent();

        this.logger.log(`subscribe on "${this.event}" event`);
        this.appEmitter.on(this.event, this.handleEvent.bind(this));
    }

    protected setEvent(): void {
        throw new Error('not implemented');
    }

    protected async doAction(chat: Chat, message: IMessage): Promise<IMessage> {
        throw new Error('not implemented');
    }

    private async handleEvent(message: IMessage) {
        try {
            this.logger.log(`"${this.event}" event received`);

            const chatId: number = message.chatId;
            const chat: Chat = await this.storageService.ensureChat(chatId);
            message = await this.doAction(chat, message);

            message.answer(
                this.templateService.apply(
                    {
                        action: this.event,
                        status: message.getReplyStatus(),
                        lang: message.lang,
                    },
                    message.getReplyData(),
                ),
            );
        } catch (error) {
            this.logger.error(error);
            message.answer(error.message);
        }
    }
}

Задачей дочерних классов BaseAction является выполнение метода doAction принимающего на вход:


  1. Chat который был создан или определен в базовом классе
  2. Объект удовлетворяющий протоколу IMessage.

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


EventAddAction
import {Injectable} from '@nestjs/common';

import * as statuses from './statuses';
import {parseEventDate, formatEventDate} from '../common/utils';
import {BaseAction} from './base.action';
import {Chat} from '../storage/models/chat';
import {Event} from '../storage/models/event';
import {IMessage} from '../message/i-message';

@Injectable()
export class EventAddAction extends BaseAction {
    protected setEvent(): void {
        this.event = this.appEmitter.EVENT_ADD;
    }

    protected async doAction(chat: Chat, message: IMessage): Promise<IMessage> {
        await this.storageService.markChatEventsInactive(chat);

        const eventDate: Date = parseEventDate(message.text.trim());

        if (!eventDate) {
            return message.setStatus(statuses.STATUS_INVALID_DATE);
        }

        const event: Event = await this.storageService.appendChatActiveEvent(
            chat,
            eventDate,
        );

        return message.setStatus(statuses.STATUS_SUCCESS).withData({
            date: formatEventDate(event.date),
        });
    }
}

EventRemoveAction
import {Injectable} from '@nestjs/common';

import * as statuses from './statuses';
import {formatEventDate} from '../common/utils';
import {BaseAction} from './base.action';
import {Chat} from '../storage/models/chat';
import {Event} from '../storage/models/event';
import {IMessage} from '../message/i-message';

@Injectable()
export class EventRemoveAction extends BaseAction {
    protected setEvent(): void {
        this.event = this.appEmitter.EVENT_REMOVE;
    }

    protected async doAction(chat: Chat, message: IMessage): Promise<IMessage> {
        const activeEvent: Event = await this.storageService.findChatActiveEvent(
            chat,
        );
        await this.storageService.markChatEventsInactive(chat);

        if (activeEvent) {
            return message.setStatus(statuses.STATUS_SUCCESS).withData({
                date: formatEventDate(activeEvent.date),
            });
        } else {
            return message.setStatus(statuses.STATUS_NO_EVENT);
        }
    }
}

EventInfoAction
import {Injectable, Logger} from '@nestjs/common';

import * as statuses from './statuses';
import {formatEventDate} from '../common/utils';
import {ConfigService} from '../common/config.service';
import {AppEmitter} from '../common/event-bus.service';
import {TemplateService} from '../common/template.service';
import {StorageService} from '../storage/storage.service';
import {BaseAction} from './base.action';
import {PlayerHelper} from './player.helper';
import {Chat} from '../storage/models/chat';
import {Event} from '../storage/models/event';
import {Player} from '../storage/models/player';
import {IMessage} from '../message/i-message';

@Injectable()
export class EventInfoAction extends BaseAction {
    private playerHelper: PlayerHelper;

    constructor(
        config: ConfigService,
        appEmitter: AppEmitter,
        logger: Logger,
        templateService: TemplateService,
        playerHelper: PlayerHelper,
        storageService: StorageService,
    ) {
        super(config, appEmitter, logger, templateService, storageService);

        this.playerHelper = playerHelper;
    }

    protected setEvent(): void {
        this.event = this.appEmitter.EVENT_INFO;
    }

    protected async doAction(chat: Chat, message: IMessage): Promise<IMessage> {
        const activeEvent: Event = await this.storageService.findChatActiveEvent(
            chat,
        );

        if (!activeEvent) {
            return message.setStatus(statuses.STATUS_NO_EVENT);
        }

        const players: Player[] = await this.storageService.getPlayers(
            activeEvent,
        );

        return message.setStatus(statuses.STATUS_SUCCESS).withData({
            date: formatEventDate(activeEvent.date),
            ...(await this.playerHelper.getPlayersList(activeEvent)),
        });
    }
}

PlayerAddAction
import {Injectable, Logger} from '@nestjs/common';

import * as statuses from './statuses';
import {ConfigService} from '../common/config.service';
import {AppEmitter} from '../common/event-bus.service';
import {TemplateService} from '../common/template.service';
import {StorageService} from '../storage/storage.service';
import {BaseAction} from './base.action';
import {PlayerHelper} from './player.helper';
import {Chat} from '../storage/models/chat';
import {Event} from '../storage/models/event';
import {Player} from '../storage/models/player';
import {IMessage} from '../message/i-message';

@Injectable()
export class PlayerAddAction extends BaseAction {
    private playerHelper: PlayerHelper;

    constructor(
        config: ConfigService,
        appEmitter: AppEmitter,
        logger: Logger,
        templateService: TemplateService,
        playerHelper: PlayerHelper,
        storageService: StorageService,
    ) {
        super(config, appEmitter, logger, templateService, storageService);

        this.playerHelper = playerHelper;
    }

    protected setEvent(): void {
        this.event = this.appEmitter.PLAYER_ADD;
    }

    protected async doAction(chat: Chat, message: IMessage): Promise<IMessage> {
        const activeEvent: Event = await this.storageService.findChatActiveEvent(
            chat,
        );

        if (!activeEvent) {
            return message.setStatus(statuses.STATUS_NO_EVENT);
        }

        const name: string = this.playerHelper.getPlayerName(message);
        const existedPlayer: Player = await this.storageService.findPlayer(
            activeEvent,
            name,
        );

        if (existedPlayer) {
            return message
                .setStatus(statuses.STATUS_ALREADY_ADDED)
                .withData({name});
        }

        const newPlayer: Player = await this.storageService.addPlayer(
            activeEvent,
            name,
        );

        return message.setStatus(statuses.STATUS_SUCCESS).withData({
            name: newPlayer.name,
            ...(await this.playerHelper.getPlayersList(activeEvent)),
        });
    }
}

PlayerRemoveAction
import {Injectable, Logger} from '@nestjs/common';

import * as statuses from './statuses';
import {ConfigService} from '../common/config.service';
import {AppEmitter} from '../common/event-bus.service';
import {TemplateService} from '../common/template.service';
import {StorageService} from '../storage/storage.service';
import {BaseAction} from './base.action';
import {PlayerHelper} from './player.helper';
import {Chat} from '../storage/models/chat';
import {Event} from '../storage/models/event';
import {Player} from '../storage/models/player';
import {IMessage} from '../message/i-message';

@Injectable()
export class PlayerRemoveAction extends BaseAction {
    private playerHelper: PlayerHelper;

    constructor(
        config: ConfigService,
        appEmitter: AppEmitter,
        logger: Logger,
        templateService: TemplateService,
        playerHelper: PlayerHelper,
        storageService: StorageService,
    ) {
        super(config, appEmitter, logger, templateService, storageService);

        this.playerHelper = playerHelper;
    }

    protected setEvent(): void {
        this.event = this.appEmitter.PLAYER_REMOVE;
    }

    protected async doAction(chat: Chat, message: IMessage): Promise<IMessage> {
        const activeEvent: Event = await this.storageService.findChatActiveEvent(
            chat,
        );

        if (!activeEvent) {
            return message.setStatus(statuses.STATUS_NO_EVENT);
        }

        const name: string = this.playerHelper.getPlayerName(message);
        const existedPlayer: Player = await this.storageService.findPlayer(
            activeEvent,
            name,
        );

        if (!existedPlayer) {
            return message
                .setStatus(statuses.STATUS_NO_PLAYER)
                .withData({name});
        }

        await this.storageService.removePlayer(existedPlayer);

        return message.setStatus(statuses.STATUS_SUCCESS).withData({
            name,
            ...(await this.playerHelper.getPlayersList(activeEvent)),
        });
    }
}

На что здесь стоит обратить внимание:


  1. Команды /add и /remove добавляют/удаляют игрока чье имя идет после команды. Если имя не указано, то добавляется/удаляется игрок вызвавший команду.
  2. В ответ на команды /add, /remove и /info выводится обновленный список игроков для активного события.

Функциональность, необходимая для реализации (1) и (2) была вынесена в специальный вспомогательный класс:


PlayerHelper
import {Injectable} from '@nestjs/common';
import {StorageService} from '../storage/storage.service';
import {Event} from '../storage/models/event';
import {Player} from '../storage/models/player';
import {IMessage} from '../message/i-message';

@Injectable()
export class PlayerHelper {
    protected storageService: StorageService;

    constructor(storageService: StorageService) {
        this.storageService = storageService;
    }

    public getPlayerName(message: IMessage) {
        const name = message.text.trim();
        return name.length > 0 ? name : message.name;
    }

    public async getPlayersList(event: Event) {
        const players: Player[] = await this.storageService.getPlayers(event);

        return {
            total: players.length,
            players: players.map((player, index) => ({
                index: index + 1,
                name: player.name,
            })),
        };
    }
}

Собираем все вместе


Как было упомянуто в начале статьи, в качестве каркаса приложения используется фреймворк NestJS. Мне в свое время повезло лично присутствовать на докладе от создателя данной замечательной библиотеки.


Многие бойлерплейты, руководства и библиотеки NodeJS часто грешат тем, что не предлагают никакой вменяемой стратегии инициализации и связей между модулями. С ростом приложения, при отсутствии должного внимания, кодовая база превращается в мешанину из require-ов между модулями, зачастую приводя даже к циклическим зависимостям или связям там, где их в принципе не должно быть. Данные проблемы можно решить используя например продвинутые Dependency Injection контейнеры вроде awilix, но NestJS идет дальше.


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


общая структура модулей


Конфигурация


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


  1. TELEGRAM_BOT_TOKEN — уникальный токен для Telegram бота.
  2. TELEGRAM_USE_PROXY — флаг при включении которого задействуется прокси для доступа к TelegramAPI.
  3. TELEGRAM_PROXY_HOST — хост прокси сервиса для доступа к TelegramAPI.
  4. TELEGRAM_PROXY_PORT — порт прокси сервиса для доступа к TelegramAPI.
  5. TELEGRAM_PROXY_LOGIN — логин для прокси сервиса доступа к TelegramAPI.
  6. TELEGRAM_PROXY_PASSWORD — пароль для прокси сервиса доступа к TelegramAPI.
  7. VK_TOKEN — уникальный токен для VK бота.
  8. DATABASE_URL — строка подключения к БД. Используется для production окружения.

На что здесь стоит обратить внимание:


  1. TELEGRAM_BOT_TOKEN для получения этого токена достаточно воспользоваться общей инструкцией по созданию бота на стороне Telegram.
  2. VK_TOKEN — как было упомянуто выше, бот VK действует от имени сообщества. Т.е. для получения данного токена, необходимо сначала создать или взять существующее сообщество и в рамках него настроить бота. Более подробно об этом можно почитать в соответствующем разделе официальной документации.
  3. DATABASE_URL — в production окружении для хранения данных используется PostgreSQL. В моем случае я просто подключился к неожиданно хорошему DBaaS под названием elephantsql имеющему бесплатный план.

CI


"Если что-то можно автоматизировать — это нужно автоматизировать". Я разделяю этот девиз, поэтому добавил традиционную интеграцию с замечательным TravisCI для прогона тестов с помощью типичной конфигурации:


language: node_js
node_js:
  - '8'
  - '10'
  - '12'
script:
  - npm run lint
  - npm run build
  - npm run test:cov
after_script:
  - npm install -g codecov
  - codecov

Как здесь также можно заметить помимо собственно прогона тестов происходит сбор метрики покрытия кода тестами и отправка соответствующих данных в сервис codecov.


При реализации сбора Docker образа и отправки его в Docker Hub мне очень помогла отличная статься от пользователя Gonf здесь на сайте Строим домашний CI/CD при помощи GitHub Actions и Python. По аналогии с его решением я сделал свой Continuous Deployment, который запускается при публикации релиза в Github:


name: Release

on:
  release:
    types: [published]

jobs:

  build:

    runs-on: ubuntu-latest

    env:
      LOGIN: ${{ secrets.DOCKER_LOGIN }}
      NAME: ${{ secrets.DOCKER_NAME }}

    steps:
    - uses: actions/checkout@v1
    - name: Install Node.js
      uses: actions/setup-node@v1
    - name: Login to docker.io
      run:  echo ${{ secrets.DOCKER_PWD }} | docker login -u ${{ secrets.DOCKER_LOGIN }} --password-stdin
    - name: npm install and build
      run: npm ci && npm run build
    - name: Build the Docker image
      run: docker build -t $LOGIN/$NAME:${GITHUB_REF:10} -f Dockerfile .
    - name: Push image to docker.io
      run: docker push $LOGIN/$NAME:${GITHUB_REF:10}

Результат и выводы


Что же получилось в итоге. Для Telegram работа бота выглядит примерно так:
Создаем новое событие на определенную дату и время.


demo1


Игрок решил принять участие.


demo2


Игрок отменил свое участие в игре.


demo3


Похожая картина и для VK за исключением выделения текста тегами <b> и <i>.
Создаем новое событие на определенную дату и время.


demo4


Игрок отменил свое участие в игре.


demo6


Что касается Viber, то неожиданно этот месенжер оказался в силу определенных ограничений непригодным для подключения такого бота. С технической стороны проблем не возникло за исключением того, что интеграция с Viber требует использования webhooks вместо long-polling. Настоящей проблемой оказалось то, что бот в Viber не может быть использован как полноправный участник групповой беседы. Про это в частности можно почитать здесь. Поэтому в итоге от интеграции с этим мессенжером пришлось (временно?) отказаться.


В Telegram готового бота можно добавить к себе в групповой чат под именем @Tormozz48bot.


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


P.S. Спасибо всем за внимание.

Tags:
Hubs:
+18
Comments 13
Comments Comments 13

Articles