Bot para taxas. Ir ao futebol com novas tecnologias

1. Introdução


Olá pessoal. Neste artigo, descreverei meu chatbot para o serviço de mensagens de telegrama e a rede social VK usando o NodeJS.


Neste ponto, muitos leitores devem começar algo como: "Quanto tempo!" ou "O que, de novo ?!"
Sim, publicações semelhantes já estavam em um habr inclusive. Mas, no entanto, acredito que o artigo será útil. Resumidamente, o que a implementação técnica do bot representa:


  1. Como estrutura para o aplicativo, a estrutura NestJS está ganhando popularidade.
  2. A biblioteca de telegraf para interagir com a API do Telegram.
  3. A biblioteca node-vk-bot-api para interagir com a API VK.
  4. Biblioteca Typeorm para organizar a camada de armazenamento de dados.
  5. Testes usando mocha e a biblioteca chai assert .
  6. IC usando o Travis CI para teste e as Ações do GitHub para implantar imagens do docker.

Como um trabalho paralelo, vamos tentar fazer nossos amigos bot do Viber, tornando-o tão universal para uso em vários serviços de mensagens.


Para quem quer saber o que aconteceu, seja bem-vindo ao gato.


Declaração do problema


Como atividade física útil, prefiro futebol. Em um nível amador, é claro. Como membro de vários canais e bate-papos em vk, viber e telegrama, muitas vezes é necessário ver a seguinte imagem:


imagem


O que vemos aqui:


  1. "P" se adicionou.
  2. Depois dele, mais 3 pessoas foram adicionadas, entre as quais havia um certo "C".
  3. "P" adicionou seu camarada chamado "Dimon".
  4. Depois disso, "P" não ficou com preguiça e calculou que, no momento, já existem até 5 pessoas.
  5. No entanto, essas informações não foram relevantes por muito tempo, pois também decidi executar e me adicionar.

Em casos especialmente negligenciados, as pessoas podem colocar um “+” e cancelar sua participação, convidar amigos que não estão no bate-papo ou se inscrever em outros participantes do bate-papo e realizar outras atividades. No final, isso leva ao fato de que, quando todos acabam jogando, acontece que:


  1. Vasya definiu +1, significando não apenas ele mesmo, mas também seu amigo Gosh.
  2. Kolya escreveu que ele viria, mas o organizador Spiridon considerou com seus olhos apenas as vantagens.

Como resultado, temos possivelmente um número desigual de equipes, “Gosh extra” e inesperadamente apareceram Kolya.


Para evitar tais colisões, escrevi um bot simples que ajuda a exibir visualmente a composição dos participantes do próximo jogo e é conveniente adicionar (s) / excluir (s).


Portanto, na minha implementação básica, na minha opinião, o bot deve ser capaz de:


  1. Ser incorporado em qualquer número de bate-papos. Armazene seu estado para cada bate-papo separadamente.
  2. Usando o comando / event_add, crie um novo evento ativo com determinadas informações e uma lista vazia de jogadores. O evento ativo anterior deve ficar inativo.
  3. O comando / event_remove deve cancelar o evento ativo no momento.
  4. O comando / add deve adicionar um novo membro ao evento ativo no momento. Além disso, se o comando for chamado sem nenhum texto adicional, então quem digitou o comando deve ser adicionado. Caso contrário, é adicionada uma pessoa cujo nome é especificado quando o comando é chamado.
  5. O comando / remove remove uma pessoa da lista de participantes de acordo com as regras descritas para o comando / add .
  6. O comando / info permite que você veja quem já se inscreveu no jogo.
  7. É altamente desejável que o bot responda no mesmo idioma que o player configurado (ou em inglês por padrão).

Para ver rapidamente o que aconteceu no final, você pode ir imediatamente para a última seção "Resultado e conclusões". Bem, se você estiver interessado nos detalhes da implementação, essas informações serão descritas em detalhes suficientes abaixo.


Design e desenvolvimento


Ao projetar o aplicativo, o seguinte esquema apareceu na minha cabeça:


imagem


Aqui, a idéia principal era trazer todas as especificidades do trabalho com vários sistemas de mensagens para os adaptadores apropriados e encapsular a lógica da interação com as bibliotecas implementando as APIs correspondentes, processando e trazendo dados para um único formulário.


Modelos de interface e mensagem


Para mensagens, foi possível projetar a seguinte interface IMessage , que é satisfeita pelas classes que armazenam dados de mensagens recebidas para vários sistemas e métodos para trabalhar com esses dados:


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; } 

A classe base BaseMessage que implementa essa interface tem o seguinte formato:


Mensagem base
 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'); } } } 

Classe de mensagem para o 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); } } 

e para 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; } } 

O que você deve prestar atenção:


  1. chatId é o identificador exclusivo para o bate-papo. Para o Telegram, ele vem explicitamente na estrutura de cada mensagem. No entanto, no caso de VK, não existe esse identificador explicitamente. Como ser Para responder a essa pergunta, você precisa entender como o bot funciona no VK. Nesse sistema, um bot na correspondência de grupo atua em nome da comunidade para a qual ele inicia. I.e. cada mensagem bot contém um identificador de comunidade group_id . Além disso, peer_id aparece na mensagem (mais detalhes podem ser lidos aqui ) como o identificador da conversa em grupo no nosso caso. Com base nesses dois identificadores, você pode criar seu identificador de bate-papo, por exemplo, da seguinte maneira:


     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; } 

    Esse método certamente não é perfeito, mas é aplicável à tarefa atual.


  2. fullText e texto . Como o nome das variáveis indica , fullText contém o texto completo da mensagem, enquanto o texto contém apenas a parte que vem após o nome do comando.



Isso é conveniente porque permite que você use o campo de texto pronto para analisar as informações auxiliares contidas nele, como a data do evento ou o nome do player.


  1. Diferentemente do Telegram, as mensagens VK não suportam um conjunto de tags para destacar texto como <b> ou <i> ; portanto, ao responder, essas tags devem ser excluídas usando expressões regulares.

Adaptadores para receber e enviar mensagens


Depois de criar a interface e as estruturas de dados para mensagens Telegram e VK, chegou a hora de implementar serviços para o processamento de eventos recebidos.


imagem


Esses serviços devem inicializar módulos de terceiros para interagir com as APIs Telegram e VK e iniciar mecanismos de pesquisa longa, além de ter uma conexão entre comandos recebidos e eventos internos que ocorrem no sistema ao receber dados de serviços de mensagens.


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], ]; } } 

O que você deve prestar atenção:


  1. Devido a alguns problemas de acesso ao serviço Telegram (oi Roskomnadzor), era necessário usar opcionalmente um proxy que fornecesse o pacote socks5-https-client .
  2. Ao processar um evento, um campo é adicionado ao contexto com o valor do comando com o texto do qual a mensagem foi recebida.
  3. Para o VK, você deve baixar separadamente os dados do usuário que enviou a mensagem usando uma chamada de API separada:
     const [from] = await this.bot.execute('users.get', { user_ids: ctx.message.from_id, }); 

Modelo de dados


Para implementar a funcionalidade declarada do bot, três modelos foram suficientes:


  1. Chat -papo - bate-papo ou conversa.
  2. Event - um evento para um bate-papo agendado para uma data e hora específicas.
  3. Player - participante ou jogador participando do evento.

Esquema de dados


As implementações de modelos usando a biblioteca typeorm são:


Bate-papo
 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[]; } 

Evento
 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[]; } 

Jogador
 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; } 

Aqui é necessário discutir novamente o mecanismo do bot para várias equipes em termos de manipulação dos modelos de bate-papo , evento e jogador .


  1. Após o recebimento de qualquer comando, a existência de um bate-papo no banco de dados é verificada com o chatId . Se não houver registro correspondente, ele será criado.
  2. Para simplificar a lógica, cada bate-papo pode ter apenas um evento ativo ( Evento ). I.e. quando o comando /event_add é /event_add , um novo evento ativo com a data especificada é criado no sistema
    O evento ativo atual (se existir) se torna inativo.
  3. /event_remove localiza o evento ativo no bate-papo atual e o desativa.
  4. /info localiza um evento ativo no chat atual, exibe informações sobre esse evento e uma lista de jogadores ( Player ).
  5. /add e /remove trabalho com o evento ativo adicionando e removendo jogadores dele.

O serviço correspondente para trabalhar com dados é o seguinte:


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); } } 

Resposta do modelo


Além do serviço StorageService descrito para trabalhar com dados, havia também a necessidade de implementar um serviço TemplateService dedicado cujas tarefas no momento são:


  1. Baixe e compile modelos de guidão para opções de resposta para vários comandos em diferentes idiomas.
  2. Selecionando um modelo adequado, dependendo do evento atual, status da resposta e idioma do usuário.
  3. Preenchendo o modelo com dados obtidos como resultado do comando.

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}`; } } 

O que você deve prestar atenção:


  1. Os arquivos de modelo estão em um diretório ./templates separado na raiz do projeto e são estruturados por idioma, ação (comando) e status de resposta.


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

    Quando o aplicativo é inicializado, todos os modelos de resposta são carregados na memória e preenchem um Mapa com chaves que identificam exclusivamente o modelo:


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

  2. Atualmente, o sistema possui 2 conjuntos de modelos: para russo e inglês.
    O fallback é fornecido no idioma padrão (inglês) na ausência do modelo necessário para o idioma do evento atual.


  3. Os próprios modelos de guidão , por exemplo:


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

    contêm espaços reservados tradicionais e um conjunto de tags válidas para formatar respostas no Telegram.



Serviços de processamento de comandos (ações)


Agora que os serviços básicos de suporte estão descritos, é hora de implementar a lógica de negócios do bot. Esquematicamente, a conexão entre os módulos é apresentada aqui:


ações


As tarefas da classe base BaseAction incluem:


  1. Inscreva-se em um evento específico do AppEmitter .
  2. A funcionalidade geral do processamento de eventos, a saber:
    • procurando por um existente ou criando um novo bate-papo no contexto em que a equipe será processada.
    • Uma chamada para o método do modelo doAction implementação é individual para cada classe da BaseAction herdada.
    • Aplicando modelos à resposta doAction resultante usando o TemplateService .

Ação base
 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); } } } 

A tarefa das classes filho BaseAction é executar o método doAction da entrada:


  1. Bate-papo criado ou definido na classe base
  2. Um objeto em conformidade com o protocolo IMessage .

Como resultado da execução desse método, o IMessage também é retornado, mas com o status estabelecido para a escolha do modelo certo e dos dados que participarão do modelo de resposta.


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)), }); } } 

O que você deve prestar atenção:


  1. Os comandos /add e /remove adicionam / removem o player cujo nome vem após o comando. Se o nome não for especificado, o jogador que chamou a equipe será adicionado / removido.
  2. Em resposta aos comandos /add , /remove e /info , uma lista atualizada de jogadores para o evento ativo é exibida.

A funcionalidade necessária para implementar (1) e (2) foi movida para uma classe auxiliar especial:


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, })), }; } } 

Juntando tudo


Conforme mencionado no início do artigo, a estrutura do NestJS é usada como a estrutura do aplicativo. Ao mesmo tempo, tive a sorte de assistir pessoalmente ao relatório do criador desta maravilhosa biblioteca.


Muitos clichês, manuais e bibliotecas do NodeJS geralmente pecam por não oferecer nenhuma estratégia de inicialização sadia e conexões entre os módulos. Com o crescimento do aplicativo, na ausência de atenção adequada, a base de código se torna uma mistura de requisitos entre os módulos, geralmente levando a dependências ou relacionamentos cíclicos onde, em princípio, não deveriam existir. Dependency Injection awilix , NestJS .


DI , NodeJS , , .


estrutura geral dos módulos


Configuração


.


  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 . I.e. , . .
  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 . , .


PS .

Source: https://habr.com/ru/post/pt483194/


All Articles